├── log └── .placeholder ├── tmp └── cache │ └── .gitkeep ├── .rspec ├── lib ├── version.rb ├── irb_console.rb ├── jira │ ├── attachment.rb │ ├── comment.rb │ ├── client.rb │ └── ticket.rb ├── bot │ ├── command.rb │ ├── comment_scanner.rb │ ├── trello.rb │ └── tracked_card.rb └── trello_jira_bridge.rb ├── .gitignore ├── tasks ├── console.rake └── fixtures.rake ├── spec ├── support │ └── fixtures.rb ├── bot │ ├── trello_spec.rb │ ├── command_spec.rb │ ├── comment_scanner_spec.rb │ └── tracked_card_spec.rb ├── factories │ └── trello_factories.rb ├── spec_helper.rb ├── jira │ └── ticket_spec.rb └── fixtures │ └── jira │ ├── WS-1080.json │ ├── WS-1300.json │ ├── WS-1299.json │ ├── WS-1230.json │ └── SYS-1916.json ├── config ├── config.yml └── config-example.yml ├── Rakefile ├── Gemfile ├── bin └── trello-jira-bot ├── trello-jira-bridge.gemspec ├── Guardfile ├── Gemfile.lock └── README.md /log/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/cache/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /lib/version.rb: -------------------------------------------------------------------------------- 1 | module TrelloJira 2 | VERSION = "0.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rvmrc 2 | db/structure.sql 3 | config/settings.yml 4 | db/data* 5 | tmp/* 6 | log/*.log 7 | .bundle 8 | .rvmrc 9 | .ruby-version 10 | .ruby-gemset 11 | config/br.yml 12 | -------------------------------------------------------------------------------- /tasks/console.rake: -------------------------------------------------------------------------------- 1 | 2 | desc "Open an irb session preloaded with this library" 3 | task :console do 4 | sh "irb -rubygems -I lib -r trello_jira_bridge -r wirble -r irb_console" 5 | end 6 | -------------------------------------------------------------------------------- /lib/irb_console.rb: -------------------------------------------------------------------------------- 1 | begin 2 | # load wirble 3 | require 'wirble' 4 | 5 | # start wirble (with color) 6 | Wirble.init(:init_color => true) 7 | rescue LoadError => err 8 | warn "Couldn't load Wirble: {err}" 9 | end 10 | 11 | TrelloJiraBridge.load_config 12 | -------------------------------------------------------------------------------- /spec/support/fixtures.rb: -------------------------------------------------------------------------------- 1 | def load_fixture(name) 2 | content = File.read(File.expand_path("../../fixtures/#{name}", __FILE__)) 3 | JSON.parse(content) 4 | end 5 | 6 | def stub_jira_ticket_request(ticket_id) 7 | fixture = load_fixture("jira/#{ticket_id}.json") 8 | Jira::Client.should_receive(:get).with(ticket_id).at_least(:once).and_return(fixture) 9 | end 10 | -------------------------------------------------------------------------------- /tasks/fixtures.rake: -------------------------------------------------------------------------------- 1 | 2 | desc "update test fixtures from JIRA" 3 | task :update_fixtures do 4 | Dir.glob('spec/fixtures/jira/*').each do |filename| 5 | ticket_id = File.basename(filename).split('.').first 6 | File.open("spec/fixtures/jira/#{ticket_id}.json","w") do |file| 7 | file.write(Jira::Client.get(ticket_id).to_json) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/config.yml: -------------------------------------------------------------------------------- 1 | trello: 2 | user: jirabot 3 | email: jirabot@my-company.com 4 | secret: 45fe8c7b87afd904e81b088168861c6396eead6bb03eb2905640ce002bb4ce60 5 | app_key: dc10e4abcf391a9c9787c7f8197dfb5c65db1b8e7243c38b25c0b2df7d772544 6 | public_key: 601223843e8a22fadaf4db2edf9370da 7 | 8 | jira: 9 | site: http://jira.my-company.com 10 | project: ENG 11 | user: trello_bot 12 | password: secret 13 | -------------------------------------------------------------------------------- /config/config-example.yml: -------------------------------------------------------------------------------- 1 | trello: 2 | user: jirabot 3 | email: jirabot@my-company.com 4 | secret: 45fe8c7b87afd904e81b088168861c6396eead6bb03eb2905640ce002bb4ce60 5 | app_key: dc10e4abcf391a9c9787c7f8197dfb5c65db1b8e7243c38b25c0b2df7d772544 6 | public_key: 601223843e8a22fadaf4db2edf9370da 7 | 8 | jira: 9 | site: http://jira.my-company.com 10 | project: ENG 11 | user: trello_bot 12 | password: secret 13 | 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift 'lib' 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | require "bundler/gem_tasks" 6 | 7 | require 'rspec/core/rake_task' 8 | require 'rake/clean' 9 | require 'trello_jira_bridge' 10 | 11 | TrelloJiraBridge.load_config 12 | 13 | #StandaloneMigrations::Tasks.load_tasks 14 | 15 | 16 | # load 'tasks/*.rake' 17 | Dir.glob('tasks/**/*.rake').each { |r| Rake.application.add_import r } 18 | 19 | RSpec::Core::RakeTask.new(:spec) 20 | 21 | task :default => :spec 22 | -------------------------------------------------------------------------------- /lib/jira/attachment.rb: -------------------------------------------------------------------------------- 1 | module Jira 2 | class Attachment 3 | attr_accessor :filename, :author, :content, :thumbnail, :mime_type, :created, :size 4 | 5 | def initialize(json) 6 | self.filename = json["filename"] 7 | self.content = json["content"] 8 | self.thumbnail = json["thumbnail"] 9 | self.author = json["author"]["displayName"] 10 | self.mime_type = json["mimeType"] 11 | self.size = json["size"] 12 | self.created = DateTime.parse(json["created"]) 13 | end 14 | 15 | def download 16 | Jira::Client.download(self.content) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | 2 | source "http://rubygems.org" 3 | 4 | gem "activesupport" 5 | gem "activemodel" 6 | # gem "activerecord" 7 | # gem "standalone_migrations" 8 | # gem "sqlite3" 9 | 10 | gem "rspec" 11 | gem "rake" 12 | 13 | gem "ruby-trello",'0.4.4.1' 14 | gem "awesome_print" 15 | #gem "rest-client", :git => 'git://github.com/rest-client/rest-client.git' 16 | gem "rest-client" 17 | gem "moneta" 18 | gem "api_cache" 19 | gem "json", '1.8.2' 20 | 21 | group :development do 22 | gem 'wirble' 23 | end 24 | 25 | group :test do 26 | gem 'guard' 27 | gem 'guard-rspec' 28 | gem 'guard-bundler' 29 | gem "factory_girl", "2.3.2" 30 | gem 'rb-fsevent', '~> 0.9.1' 31 | end 32 | -------------------------------------------------------------------------------- /spec/bot/trello_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Bot::Trello do 5 | context "basics" do 6 | 7 | before do 8 | @member = Factory.build(:trello_member, :username => 'bot') 9 | Trello::Member.should_receive(:find).and_return(@member) 10 | @bot = Bot::Trello.new 11 | end 12 | 13 | it "should instantiate" do 14 | @bot.should be_instance_of Bot::Trello 15 | end 16 | 17 | it "should set the username" do 18 | @bot.member.username.should == 'bot' 19 | end 20 | 21 | it "should set up the trello cards" do 22 | @bot.member.cards.should == [] 23 | end 24 | 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /bin/trello-jira-bot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $LOAD_PATH.unshift 'lib' 4 | 5 | require 'bundler' 6 | Bundler.setup 7 | 8 | require 'optparse' 9 | require 'trello_jira_bridge' 10 | 11 | options = { 12 | config: 'config/config.yml' 13 | } 14 | optparse = OptionParser.new do |opts| 15 | opts.on('-h','--help','Display this screen') do 16 | puts opts 17 | exit 18 | end 19 | opts.on('-c','--config FILENAME', 'Path to settings yamlfile. Default: config.yml') do |f| 20 | options[:config] = f 21 | end 22 | end 23 | 24 | optparse.parse! 25 | 26 | TrelloJiraBridge.load_config(options[:config]) 27 | 28 | LOG.info "Scanning Trello cards as @#{Bot::Trello.config.username}" 29 | Bot::Trello.new.update 30 | 31 | -------------------------------------------------------------------------------- /lib/jira/comment.rb: -------------------------------------------------------------------------------- 1 | module Jira 2 | class Comment 3 | attr_accessor :api_link, :author, :body, :created, :updated 4 | attr_accessor :comment_id, :web_link 5 | 6 | def initialize(json) 7 | self.api_link = json["self"] 8 | self.author = json["author"]["displayName"] 9 | self.body = json["body"] 10 | self.created = DateTime.parse(json["created"]) 11 | self.updated = DateTime.parse(json["updated"]) 12 | self.comment_id = self.api_link.split('/').last # last part of api link contains the comment id 13 | end 14 | 15 | def header 16 | "Comment by #{self.author}, #{self.date}" 17 | end 18 | 19 | def date 20 | self.created.strftime('%v %R') 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/bot/command_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe Bot::Command do 5 | before do 6 | end 7 | 8 | it "should extract commands" do 9 | command = Bot::Command.extract('@bot track WS-1234') 10 | command.name.should == 'track' 11 | command.ticket_id.should == 'WS-1234' 12 | end 13 | 14 | it "should extract commands and tickets with a full url" do 15 | command = Bot::Command.extract('@bot track https://jira.mycorp.com/browse/WS-1234') 16 | command.name.should == 'track' 17 | command.ticket_id.should == 'WS-1234' 18 | end 19 | 20 | it "should not extract unrecognized commands" do 21 | command = Bot::Command.extract('Hey what is going on with WS-1234?') 22 | command.should be_nil 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/bot/command.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | class Command 3 | attr_accessor :name, :ticket_id 4 | 5 | SUPPORTED_COMMANDS = %w(track untrack import) # TODO: add close, comment 6 | 7 | def initialize(name, ticket_id) 8 | self.name = name 9 | self.ticket_id = ticket_id 10 | end 11 | 12 | def summary 13 | "#{self.name} #{self.ticket_id}" 14 | end 15 | 16 | def self.command_regex 17 | bot_username = Bot::Trello.config.username 18 | command_matcher = SUPPORTED_COMMANDS.join('|') 19 | jira_matcher = '\S+' 20 | %r{\@#{bot_username}\s+(#{command_matcher})\s+(#{jira_matcher})}i 21 | end 22 | 23 | def self.extract(command_string) 24 | matches = command_string.match(self.command_regex) 25 | if matches 26 | name = matches[1].downcase 27 | ticket_id = matches[2].upcase 28 | ticket_id = ticket_id.scan(/[\w\d-]+$/).to_a.first 29 | return Bot::Command.new(name, ticket_id) 30 | else 31 | return nil 32 | end 33 | end 34 | 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /trello-jira-bridge.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "trello-jira-bot" 7 | s.version = TrelloJira::VERSION 8 | s.authors = ["Jeremy Seitz"] 9 | s.email = ["jeremy@somebox.com"] 10 | s.homepage = "https://github.com/local-ch/trello-jira-bridge" 11 | s.summary = %q{Ruby library and command-line tool for integrating workflow between Trello and JIRA.} 12 | s.description = %q{} 13 | 14 | s.rubyforge_project = "trello-jira-bot" 15 | 16 | s.files = `git ls-files`.split("\n") 17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | s.require_paths = ["lib"] 20 | 21 | # specify any dependencies here; for example: 22 | 23 | s.add_runtime_dependency "rest-client" 24 | s.add_runtime_dependency "ruby-trello" 25 | 26 | s.add_development_dependency "guard" 27 | s.add_development_dependency "guard-test" 28 | end 29 | -------------------------------------------------------------------------------- /lib/jira/client.rb: -------------------------------------------------------------------------------- 1 | module Jira 2 | class Client 3 | include ActiveSupport::Configurable 4 | config_accessor :site, :user, :password 5 | 6 | def self.client 7 | @client ||= RestClient::Resource.new("#{self.config.site}/rest/api/latest/issue/", 8 | :user => self.config.user, 9 | :password => self.config.password, 10 | :content_type => 'application/json' 11 | ) 12 | end 13 | 14 | def self.get(ticket_id) 15 | response = self.client[ticket_id].get 16 | JSON.parse(response.body) 17 | end 18 | 19 | # Downloads a file. Returns a Tempfile 20 | def self.download(url) 21 | FileUtils.mkdir_p('tmp/attachments') 22 | file = File.open("tmp/attachments/#{File.basename(url)}", 'w') 23 | file.binmode 24 | file.write RestClient::Request.execute( 25 | :method => :get, 26 | :url => url, 27 | :user => Jira::Client.config.user, 28 | :password => Jira::Client.config.password 29 | ) 30 | file.close 31 | File.open(file.path) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/factories/trello_factories.rb: -------------------------------------------------------------------------------- 1 | 2 | # Trello 3 | # ------ 4 | 5 | # Trello::Member 6 | 7 | Factory.define :trello_member, :class=>'OpenStruct' do |f| 8 | f.username 'user_123' 9 | f.id 'abc123' 10 | f.cards [] 11 | end 12 | 13 | # Trello::Card 14 | 15 | Factory.define :trello_card, :class => 'OpenStruct' do |f| 16 | f.actions [] 17 | f.name 'card name' 18 | f.description 'card long description' 19 | end 20 | 21 | # Trello::Action 22 | # 23 | # Comments are actions on a Trello card. 24 | # 25 | Factory.define :user_comment, :class => 'OpenStruct' do |f| 26 | f.member_creator_id 'user_123' 27 | f.data({'text'=>'comment_text'}) 28 | f.date DateTime.now 29 | end 30 | 31 | Factory.define :command_comment_track, :class => 'OpenStruct' do |f| 32 | f.member_creator_id 'abcde' 33 | f.date DateTime.parse('2012-10-7') 34 | f.data({'text' => '@bot track WS-9999'}) 35 | end 36 | 37 | Factory.define :command_comment_untrack, :class => 'OpenStruct' do |f| 38 | f.member_creator_id 'abcde' 39 | f.date DateTime.parse('2012-10-14') 40 | f.data({'text' => "@bot untrack WS-9999\nThis problem is fixed."}) 41 | end 42 | 43 | Factory.define :bot_comment, :class => 'OpenStruct' do |f| 44 | f.member_creator_id '12345' 45 | f.date DateTime.parse('2012-10-9') 46 | f.data({'text' => 'Now tracking WS-9999. Status: open. http://jira.example.com/browse/WS-9999?commentId=12345#comment_12345'}) 47 | end 48 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard 'rspec' do 5 | watch(%r{^spec/.+_spec\.rb$}) 6 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 7 | watch('spec/spec_helper.rb') { "spec" } 8 | watch(%r{spec/factories/*}) { "spec" } 9 | watch(%r{lib/(.+)\.rb}) { "spec" } 10 | 11 | # # Rails example 12 | # watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 13 | # watch(%r{^app/(.*)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } 14 | # watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] } 15 | # watch(%r{^spec/support/(.+)\.rb$}) { "spec" } 16 | # watch('config/routes.rb') { "spec/routing" } 17 | # watch('app/controllers/application_controller.rb') { "spec/controllers" } 18 | 19 | # # Capybara features specs 20 | # watch(%r{^app/views/(.+)/.*\.(erb|haml)$}) { |m| "spec/features/#{m[1]}_spec.rb" } 21 | 22 | # # Turnip features and steps 23 | # watch(%r{^spec/acceptance/(.+)\.feature$}) 24 | # watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) { |m| Dir[File.join("**/#{m[1]}.feature")][0] || 'spec/acceptance' } 25 | end 26 | 27 | 28 | guard 'bundler' do 29 | watch('Gemfile') 30 | watch(/^.+\.gemspec/) 31 | end 32 | -------------------------------------------------------------------------------- /lib/trello_jira_bridge.rb: -------------------------------------------------------------------------------- 1 | Encoding.default_external = Encoding::UTF_8 2 | Encoding.default_internal = Encoding::UTF_8 3 | 4 | require 'awesome_print' 5 | require 'trello' 6 | require 'time' 7 | require 'yaml' 8 | require 'optparse' 9 | require 'rest_client' 10 | require 'moneta' 11 | require 'api_cache' 12 | 13 | require 'active_support/all' 14 | require 'active_model' 15 | 16 | require 'bot/command' 17 | require 'bot/comment_scanner' 18 | require 'bot/tracked_card' 19 | require 'bot/trello' 20 | require 'jira/attachment' 21 | require 'jira/client' 22 | require 'jira/comment' 23 | require 'jira/ticket' 24 | 25 | APICache.store = Moneta.new(:File, :dir=>'./tmp/cache/moneta') 26 | APICache.logger = Logger.new('./log/apicache.log') 27 | LOG = Logger.new('./log/bot.log') 28 | 29 | module TrelloJiraBridge 30 | def self.load_config(config_file='config/config.yml') 31 | @config = YAML.load_file(config_file) 32 | 33 | Jira::Client.config.site = @config['jira']['site'] 34 | Jira::Client.config.user = @config['jira']['user'] 35 | Jira::Client.config.password = @config['jira']['password'] 36 | Jira::Ticket.config.jira_version = @config['jira']['version'].to_i 37 | 38 | Bot::Trello.config.username = @config['trello']['user'] 39 | Bot::Trello.config.secret = @config['trello']['secret'] 40 | Bot::Trello.config.app_key = @config['trello']['app_key'] 41 | Bot::Trello.config.public_key = @config['trello']['public_key'] 42 | 43 | Bot::Trello.setup_oauth! 44 | 45 | LOG.debug 'Configuration loaded.' 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/bot/comment_scanner.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | # Trello Comment Scanner 3 | # 4 | # This class sorts and finds comments attached to a trello card. 5 | # It is used to find user commands and previous bot comments. 6 | # 7 | # Comments are Trello::Action instances, ordered newest to oldest. 8 | # 9 | class CommentScanner 10 | attr_accessor :comments 11 | attr_accessor :bot_user_id 12 | 13 | def initialize(comments, bot_user_id) 14 | self.comments = comments 15 | self.bot_user_id = bot_user_id 16 | end 17 | 18 | def bot_comments 19 | self.comments.select{|c| c.member_creator_id == self.bot_user_id} 20 | end 21 | 22 | def last_bot_comment 23 | self.bot_comments.first 24 | end 25 | 26 | def user_comments 27 | self.comments - self.bot_comments 28 | end 29 | 30 | def command_comments 31 | self.user_comments.select{|comment| CommentScanner.extract_command(comment)} 32 | end 33 | 34 | def new_command_comments 35 | self.command_comments.select{|c| c.date > self.last_posting_date} 36 | end 37 | 38 | def last_posting_date 39 | self.last_bot_comment ? self.last_bot_comment.date : DateTime.parse('1-1-2000') 40 | end 41 | 42 | # Bot::Command convenience methods 43 | 44 | def commands 45 | self.command_comments.map{|comment| CommentScanner.extract_command(comment)} 46 | end 47 | 48 | def new_commands 49 | self.new_command_comments.map{|comment| CommentScanner.extract_command(comment)} 50 | end 51 | 52 | def self.extract_command(comment) 53 | Bot::Command.extract(comment.data['text']) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/bot/comment_scanner_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | 5 | describe Bot::CommentScanner do 6 | before do 7 | @user_comment_1 = Factory.build(:command_comment_track) 8 | @bot_comment = Factory.build(:bot_comment) 9 | @user_comment_2 = Factory.build(:user_comment) 10 | @user_comment_3 = Factory.build(:command_comment_untrack) 11 | comments = [@user_comment_1, @bot_comment, @user_comment_2, @user_comment_3] 12 | 13 | @scanner = Bot::CommentScanner.new(comments, '12345') # bot user id 14 | end 15 | 16 | context "comments" do 17 | it "should find bot comments" do 18 | @scanner.bot_comments.should == [@bot_comment] 19 | end 20 | 21 | it "should find user comments" do 22 | @scanner.user_comments.should == [@user_comment_1, @user_comment_2, @user_comment_3] 23 | end 24 | 25 | it "should find last bot comment" do 26 | @scanner.last_bot_comment.should == @bot_comment 27 | end 28 | 29 | it "should find command comments" do 30 | @scanner.command_comments.should == [@user_comment_1, @user_comment_3] 31 | end 32 | 33 | it "should find new command comments" do 34 | @scanner.new_command_comments.should == [@user_comment_3] 35 | end 36 | end 37 | 38 | context "commands" do 39 | it "returns commands" do 40 | @scanner.commands.size.should == 2 41 | @scanner.commands.first.class.should == Bot::Command 42 | end 43 | 44 | it "returns new commands" do 45 | @scanner.new_commands.size.should == 1 46 | @scanner.new_commands.first.name.should == 'untrack' 47 | @scanner.new_commands.first.ticket_id.should == 'WS-9999' 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | 8 | require 'rspec' 9 | require 'active_support' 10 | require 'factory_girl' 11 | 12 | require File.expand_path("../../lib/trello_jira_bridge", __FILE__) 13 | 14 | Dir.glob(File.dirname(__FILE__) + "/factories/*").each do |factory| 15 | require factory 16 | end 17 | 18 | # Requires supporting ruby files with custom matchers and macros, etc, 19 | # in spec/support/ and its subdirectories. 20 | Dir.glob(File.expand_path("../support/**/*.rb", __FILE__)).each {|f| require f} 21 | 22 | RSpec.configure do |config| 23 | config.treat_symbols_as_metadata_keys_with_true_values = true 24 | config.run_all_when_everything_filtered = true 25 | config.filter_run :focus 26 | 27 | # Run specs in random order to surface order dependencies. If you find an 28 | # order dependency and want to debug it, you can fix the order by providing 29 | # the seed, which is printed after each run. 30 | # --seed 1234 31 | config.order = 'random' 32 | 33 | config.mock_framework = :rspec 34 | 35 | # RSpec automatically cleans stuff out of backtraces; 36 | # sometimes this is annoying when trying to debug something e.g. a gem 37 | config.backtrace_clean_patterns = [ 38 | %r{/gems/ruby.*/gems/rspec.*}, 39 | /spec\/spec_helper\.rb/, 40 | ] 41 | 42 | # setup test environment 43 | config.before(:suite) do 44 | Bot::Trello.config.username = 'bot' 45 | 46 | Jira::Client.config.site = 'https://jira.example.com' 47 | Jira::Client.config.user = 'bot' 48 | Jira::Client.config.password = 'secret' 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | activemodel (4.0.4) 5 | activesupport (= 4.0.4) 6 | builder (~> 3.1.0) 7 | activesupport (4.0.4) 8 | i18n (~> 0.6, >= 0.6.9) 9 | minitest (~> 4.2) 10 | multi_json (~> 1.3) 11 | thread_safe (~> 0.1) 12 | tzinfo (~> 0.3.37) 13 | addressable (2.5.0) 14 | public_suffix (~> 2.0, >= 2.0.2) 15 | api_cache (0.2.3) 16 | atomic (1.1.16) 17 | awesome_print (1.1.0) 18 | builder (3.1.4) 19 | coderay (1.0.8) 20 | diff-lcs (1.1.3) 21 | factory_girl (2.3.2) 22 | activesupport 23 | guard (1.5.4) 24 | listen (>= 0.4.2) 25 | lumberjack (>= 1.0.2) 26 | pry (>= 0.9.10) 27 | thor (>= 0.14.6) 28 | guard-bundler (1.0.0) 29 | bundler (~> 1.0) 30 | guard (~> 1.1) 31 | guard-rspec (2.3.1) 32 | guard (>= 1.1) 33 | rspec (~> 2.11) 34 | i18n (0.6.9) 35 | json (1.8.2) 36 | listen (0.6.0) 37 | lumberjack (1.0.2) 38 | method_source (0.8.1) 39 | mime-types (2.2) 40 | minitest (4.7.5) 41 | moneta (0.7.6) 42 | multi_json (1.9.2) 43 | oauth (0.4.7) 44 | pry (0.9.10) 45 | coderay (~> 1.0.5) 46 | method_source (~> 0.8) 47 | slop (~> 3.3.1) 48 | public_suffix (2.0.4) 49 | rake (10.0.2) 50 | rb-fsevent (0.9.2) 51 | rest-client (1.6.7) 52 | mime-types (>= 1.16) 53 | rspec (2.12.0) 54 | rspec-core (~> 2.12.0) 55 | rspec-expectations (~> 2.12.0) 56 | rspec-mocks (~> 2.12.0) 57 | rspec-core (2.12.1) 58 | rspec-expectations (2.12.0) 59 | diff-lcs (~> 1.1.3) 60 | rspec-mocks (2.12.0) 61 | ruby-trello (0.4.4.1) 62 | activemodel 63 | addressable (~> 2.3) 64 | json 65 | oauth (~> 0.4.5) 66 | rest-client (~> 1.6.7) 67 | slop (3.3.3) 68 | thor (0.16.0) 69 | thread_safe (0.3.1) 70 | atomic (>= 1.1.7, < 2) 71 | tzinfo (0.3.39) 72 | wirble (0.1.3) 73 | 74 | PLATFORMS 75 | ruby 76 | 77 | DEPENDENCIES 78 | activemodel 79 | activesupport 80 | api_cache 81 | awesome_print 82 | factory_girl (= 2.3.2) 83 | guard 84 | guard-bundler 85 | guard-rspec 86 | json (= 1.8.2) 87 | moneta 88 | rake 89 | rb-fsevent (~> 0.9.1) 90 | rest-client 91 | rspec 92 | ruby-trello (= 0.4.4.1) 93 | wirble 94 | 95 | BUNDLED WITH 96 | 1.12.4 97 | -------------------------------------------------------------------------------- /lib/bot/trello.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | 3 | module Bot 4 | class Trello 5 | include ActiveModel::Naming 6 | include ActiveSupport::Configurable 7 | include ActiveModel::AttributeMethods 8 | 9 | # Trello configuration 10 | config_accessor :username 11 | config_accessor :secret, :key, :public_key 12 | 13 | attr_accessor :member, :cards 14 | 15 | def initialize 16 | self.member = ::Trello::Member.find(self.config.username) 17 | self.cards = self.member.cards.map do |card| 18 | #p card.short_id 19 | Bot::TrackedCard.new(card, self) 20 | end 21 | end 22 | 23 | def username 24 | self.member.username 25 | end 26 | 27 | def user_id 28 | self.member.id 29 | end 30 | 31 | # The bot will look for commands and respond to them. 32 | # It responds to every command with a comment. 33 | # It will post comments and status updates as well. 34 | # New commands are found by looking for patterns like '@jirabot track ws-2345' 35 | # that have not yet been responded to. 36 | def update 37 | self.cards.each do |tracked_card| 38 | # update tracking status 39 | LOG.info " * Scanning Trello #{tracked_card.summary}" 40 | tracked_card.update_tracking_status 41 | ticket_list = tracked_card.jira_tickets.join(', ') 42 | ticket_list = 'none' if ticket_list.blank? 43 | LOG.info " (#{ticket_list})" 44 | 45 | # run new commands 46 | tracked_card.new_commands.each do |command| 47 | LOG.info " * processing new command on card #{tracked_card.short_id}: #{command.summary}" 48 | tracked_card.run(command) 49 | end 50 | 51 | # add any new comments 52 | tracked_card.jira_tickets.each do |ticket_id| 53 | if tracked_card.update_comments_from_jira(ticket_id) 54 | LOG.info " * updating comments from JIRA #{ticket_id}" 55 | end 56 | end 57 | 58 | # add any new attachments 59 | tracked_card.jira_tickets.each do |ticket_id| 60 | if tracked_card.update_attachments_from_jira(ticket_id) 61 | LOG.info " * updating attachments from JIRA #{ticket_id}" 62 | end 63 | end 64 | 65 | # check resolutions 66 | tracked_card.jira_tickets.each do |ticket_id| 67 | if tracked_card.update_resolution_status(ticket_id) 68 | LOG.info " #* JIRA #{ticket_id} marked as RESOLVED" 69 | end 70 | end 71 | end 72 | end 73 | 74 | def self.setup_oauth! 75 | $auth = ::Trello::Authorization 76 | policy = $auth::OAuthPolicy 77 | $auth.send(:remove_const, :AuthPolicy) if ::Trello::Authorization.const_defined?(:AuthPolicy) 78 | $auth.const_set :AuthPolicy, policy 79 | 80 | consumer = $auth::OAuthCredential.new(self.config.public_key, self.config.secret) 81 | $auth::OAuthPolicy.consumer_credential = consumer 82 | 83 | oauth_credential = $auth::OAuthCredential.new(self.config.app_key, nil) 84 | $auth::OAuthPolicy.token = oauth_credential 85 | true 86 | end 87 | 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/jira/ticket_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe 'jira 4' do 5 | before do 6 | Jira::Ticket.config.jira_version = 4 7 | end 8 | 9 | describe Jira::Ticket do 10 | before do 11 | stub_jira_ticket_request('WS-1299') 12 | stub_jira_ticket_request('WS-1230') 13 | stub_jira_ticket_request('WS-1080') 14 | 15 | @ticket = Jira::Ticket.get('WS-1299') 16 | @ticket_with_many_comments = Jira::Ticket.get('WS-1230') 17 | @ticket_with_attachments = Jira::Ticket.get('WS-1080') 18 | end 19 | 20 | it 'should parse the ticket id' do 21 | @ticket.ticket_id.should == 'WS-1299' 22 | end 23 | 24 | it 'should parse the api link' do 25 | @ticket.api_link.should == 'https://jira.example.com/rest/api/latest/issue/WS-1299' 26 | end 27 | 28 | it 'should parse the title' do 29 | pending "dropping jira 4 support" 30 | @ticket.title.should == 'wrong slots after calculating a route' 31 | end 32 | 33 | it 'should check if a ticket exists' do 34 | Jira::Client.should_receive(:get).and_raise(RestClient::ResourceNotFound) 35 | Jira::Ticket.exists?('ws-404').should == false 36 | end 37 | 38 | it 'should parse comments' do 39 | @ticket.comments.size.should == 1 40 | @ticket.comments.first.body.should =~ /duplicate of/ 41 | @ticket.comments.first.created.strftime('%F %T').should == '2012-10-24 17:13:44' 42 | 43 | @ticket_with_many_comments.comments.size.should == 5 44 | end 45 | 46 | it 'should parse the status' do 47 | @ticket.status.should == 'Closed' 48 | end 49 | 50 | it 'should parse dates' do 51 | @ticket.created.strftime('%F %T').should == '2012-10-02 15:18:40' 52 | @ticket.updated.strftime('%F %T').should == '2012-10-24 17:13:44' 53 | end 54 | 55 | it 'should find comments since a given date' do 56 | comments = @ticket_with_many_comments.comments_since(DateTime.parse('2012-11-01')) 57 | comments.count.should == 3 58 | end 59 | 60 | it 'should find attachments' do 61 | @ticket_with_attachments.attachments.count.should == 1 62 | end 63 | end 64 | end 65 | 66 | describe "jira 5" do 67 | before do 68 | Jira::Ticket.config.jira_version = 5 69 | end 70 | 71 | describe "ticket" do 72 | before do 73 | stub_jira_ticket_request('SYS-1916') 74 | #stub_jira_ticket_request('WS-1230') 75 | #stub_jira_ticket_request('WS-1080') 76 | 77 | @ticket = Jira::Ticket.get('SYS-1916') 78 | #@ticket_with_many_comments = Jira::Ticket.get('WS-1230') 79 | #@ticket_with_attachments = Jira::Ticket.get('WS-1080') 80 | end 81 | 82 | it "parses the description" do 83 | @ticket.description.should =~ /company.com main website dispatcher/ 84 | end 85 | 86 | it "parses the issue_type" do 87 | @ticket.issue_type.should == "Operations" 88 | end 89 | 90 | it "parses the status" do 91 | @ticket.status.should == "Closed" 92 | end 93 | 94 | it "parses the project" do 95 | @ticket.project.should == "System Engineering" 96 | end 97 | 98 | it "parses the created date" do 99 | @ticket.created.to_s.should == "2012-08-07T09:53:22+02:00" 100 | end 101 | 102 | it "parses the updated date" do 103 | @ticket.updated.to_s.should == "2013-01-04T11:03:48+01:00" 104 | end 105 | 106 | it "parses the updated date" do 107 | @ticket.resolution_date.to_s.should == "2012-10-01T17:38:08+02:00" 108 | end 109 | 110 | it "parses the priority" do 111 | @ticket.priority.should == "Minor" 112 | end 113 | 114 | 115 | 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/bot/tracked_card_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | # For these tests, Trello member '12345' is @jirabot 5 | 6 | describe Bot::TrackedCard do 7 | before do 8 | @member = Factory.build(:trello_member, :username => 'bot', :id => '12345') 9 | Trello::Member.should_receive(:find).and_return(@member) 10 | @bot = Bot::Trello.new 11 | @trello_card = Factory.build(:trello_card) 12 | @tracked_card = Bot::TrackedCard.new(@trello_card, @bot) 13 | Jira::Ticket.config.jira_version = 4 14 | Jira::Ticket.stub(:exists?).and_return(true) 15 | end 16 | 17 | it "should initialize" do 18 | @tracked_card.trello_bot.should == @bot 19 | @tracked_card.trello_card.should == @trello_card 20 | end 21 | 22 | context "ticket tracking status" do 23 | it "should add to tracked cards" do 24 | @tracked_card.track_jira('WS-1234') 25 | @tracked_card.jira_tickets.should == ['WS-1234'] 26 | end 27 | 28 | it "should remove from tracked cards" do 29 | @tracked_card.jira_tickets = ['WS-1234'] 30 | @tracked_card.untrack_jira('WS-1234') 31 | 32 | @tracked_card.jira_tickets.should == [] 33 | end 34 | 35 | it "should handle multiple updates" do 36 | @tracked_card.jira_tickets.should == [] 37 | 38 | @tracked_card.track_jira('WS-1234') 39 | @tracked_card.track_jira('WS-9999') 40 | 41 | @tracked_card.jira_tickets.should == ['WS-1234', 'WS-9999'] 42 | 43 | @tracked_card.untrack_jira('WS-1234') 44 | @tracked_card.track_jira('WS-9999') 45 | 46 | @tracked_card.jira_tickets.should == ['WS-9999'] 47 | end 48 | 49 | it "updates tracking status" do 50 | commands = [ 51 | Bot::Command.new('track','WS-1234'), 52 | Bot::Command.new('import', 'WS-4444') 53 | ] 54 | @tracked_card.should_receive(:commands).and_return(commands) 55 | @tracked_card.update_tracking_status 56 | @tracked_card.jira_tickets.should == ['WS-1234','WS-4444'] 57 | end 58 | end 59 | 60 | context "ticket command responses" do 61 | it "should add to tracked cards" do 62 | @tracked_card.trello_card.should_receive(:add_comment).at_least(:once).with(/WS-1234 is now being tracked/) 63 | 64 | @command = Bot::Command.new('track','WS-1234') 65 | @tracked_card.run(@command) 66 | end 67 | 68 | it "should remove from tracked cards" do 69 | @tracked_card.trello_card.should_receive(:add_comment).at_least(:once).with(/WS-1234 is no longer being tracked/) 70 | @tracked_card.jira_tickets = ['WS-1234'] 71 | 72 | @command = Bot::Command.new('untrack','WS-1234') 73 | @tracked_card.run(@command) 74 | end 75 | end 76 | 77 | context "update card from jira" do 78 | before do 79 | stub_jira_ticket_request('WS-1230') 80 | @tracked_card.should_receive(:last_posting_date).and_return(DateTime.parse('2012-11-01')) 81 | end 82 | 83 | it "should post comments" do 84 | @tracked_card.trello_card.should_receive(:add_comment).exactly(3).times 85 | @tracked_card.update_comments_from_jira('WS-1230') 86 | end 87 | end 88 | 89 | context "import card from jira" do 90 | before do 91 | pending "dropping jira 4 support" 92 | stub_jira_ticket_request('WS-1230') 93 | Jira::Ticket.config.jira_version = 4 94 | @tracked_card.trello_card.should_receive(:save).and_return(true) 95 | end 96 | 97 | it "should import details" do 98 | pending "dropping jira 4 support" 99 | @tracked_card.import_content_from_jira('WS-1230') 100 | @tracked_card.trello_card.name.should =~ /WS-1230: Adress mismatch on map.company.com/ 101 | end 102 | end 103 | 104 | end 105 | -------------------------------------------------------------------------------- /lib/jira/ticket.rb: -------------------------------------------------------------------------------- 1 | module Jira 2 | class Ticket 3 | include ActiveSupport::Configurable 4 | config_accessor :site, :user, :password, :jira_version 5 | 6 | attr_accessor :ticket_id, :api_link, :title, :description 7 | attr_accessor :issue_type, :priority, :status, :project 8 | attr_accessor :created, :updated, :resolution_date 9 | 10 | # has many 11 | attr_accessor :comments, :attachments 12 | 13 | def initialize(json) 14 | #puts json.to_json 15 | fields = json["fields"] 16 | @jira_version = self.class.config.jira_version 17 | 18 | self.ticket_id = json["key"] 19 | self.api_link = json["self"] 20 | 21 | self.title = fields["summary"] 22 | if @jira_version == 4 23 | # JIRA V4 24 | self.issue_type = fields["issuetype"]["value"]["name"] 25 | self.description = fields["description"]["value"] 26 | self.status = fields["status"]["value"]["name"] 27 | self.project = fields["project"]["value"]["name"] 28 | self.created = DateTime.parse(fields["created"]["value"]) 29 | self.updated = DateTime.parse(fields["updated"]["value"]) 30 | self.resolution_date = fields["resolutiondate"].present? ? DateTime.parse(fields["resolutiondate"]["value"]) : nil 31 | self.priority = fields.fetch("priority",{}).fetch("value",{}).fetch("name",{}) 32 | 33 | self.attachments = fields["attachment"]["value"].map do |attachment_json| 34 | Jira::Attachment.new(attachment_json) 35 | end 36 | 37 | self.comments = fields["comment"]["value"].map do |comment_json| 38 | Jira::Comment.new(comment_json) 39 | end 40 | else 41 | # JIRA V5 42 | self.issue_type = fields["issuetype"]["name"] 43 | self.description = fields["description"] 44 | self.status = fields["status"]["name"] 45 | self.project = fields["project"]["name"] 46 | self.created = DateTime.parse(fields["created"]) 47 | self.updated = DateTime.parse(fields["updated"]) 48 | self.resolution_date = fields["resolutiondate"].present? ? DateTime.parse(fields["resolutiondate"]) : nil 49 | self.priority = fields["priority"].present? ? fields["priority"]["name"] : '' 50 | 51 | self.attachments = fields["attachment"].to_a.map do |attachment_json| 52 | Jira::Attachment.new(attachment_json) 53 | end 54 | 55 | self.comments = fields["comment"].fetch("comments",[]).map do |comment_json| 56 | Jira::Comment.new(comment_json) 57 | end 58 | end 59 | 60 | 61 | 62 | self 63 | end 64 | 65 | def self.get(ticket_id) 66 | json = Jira::Client.get(ticket_id) 67 | self.new(json) 68 | end 69 | 70 | def self.exists?(ticket_id) 71 | begin 72 | Jira::Client.get(ticket_id) && true 73 | rescue RestClient::ResourceNotFound, RestClient::MethodNotAllowed 74 | warn("Jira ticket #{ticket_id} not found") 75 | false 76 | end 77 | end 78 | 79 | def web_link 80 | "#{Jira::Client.config.site}/browse/#{self.ticket_id}" 81 | end 82 | 83 | def comment_web_link(comment) 84 | "#{self.web_link}?focusedCommentId=#{comment.comment_id}#comment-#{comment.comment_id}" 85 | end 86 | 87 | def summary 88 | "JIRA #{self.ticket_id}: #{self.title}" 89 | end 90 | 91 | def comments_since(date) 92 | self.comments.select{|c| c.created > date} 93 | end 94 | 95 | def attachments_since(date) 96 | self.attachments.select{|a| a.created > date} 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/bot/tracked_card.rb: -------------------------------------------------------------------------------- 1 | =begin 2 | 3 | Usage: 4 | 5 | card = Bot::TrackedCard.new(trello_card, trello_bot) 6 | card.tracked_jira_tickets # => [, ] 7 | card.process_new_commands! 8 | 9 | =end 10 | module Bot 11 | class TrackedCard 12 | include ActiveSupport::Configurable 13 | config_accessor :site, :user, :password 14 | 15 | attr_accessor :trello_card, :trello_bot 16 | attr_accessor :jira_tickets, :last_bot_update 17 | 18 | def initialize(trello_card, trello_bot) 19 | self.trello_card = trello_card 20 | self.trello_bot = trello_bot 21 | self.jira_tickets = [] 22 | end 23 | 24 | def summary 25 | title = self.trello_card.name 26 | "##{self.short_id} (#{self.trello_card.board.name})" 27 | end 28 | 29 | def short_id 30 | self.trello_card.short_id 31 | end 32 | 33 | def add_comment(text) 34 | self.trello_card.add_comment(text) 35 | end 36 | 37 | # If the ticket in JIRA has been marked as "resolved" since our last posting, 38 | # comment on the Trello ticket with the date. 39 | def update_resolution_status(ticket_id) 40 | jira_ticket = self.get_jira_ticket(ticket_id) 41 | if jira_ticket.resolution_date 42 | if jira_ticket.resolution_date > self.last_posting_date 43 | timestamp = jira_ticket.resolution_date.strftime('%v %R') 44 | self.add_comment("#{jira_ticket.ticket_id} was RESOLVED on #{timestamp}.") 45 | return true 46 | end 47 | end 48 | false 49 | end 50 | 51 | def converted_markup(string) 52 | string.gsub('{code}',"\n```") 53 | end 54 | 55 | def update_comments_from_jira(ticket_id, force=false) 56 | is_updated = false 57 | jira_ticket = self.get_jira_ticket(ticket_id) 58 | comments = force ? jira_ticket.comments : jira_ticket.comments_since(self.last_posting_date) 59 | comments.each do |comment| 60 | link = jira_ticket.comment_web_link(comment) 61 | header = "[Comment by #{comment.author}](#{link}), #{comment.date}" 62 | text = [header, '---', converted_markup(comment.body)].join("\n") 63 | self.add_comment("**#{jira_ticket.ticket_id}**: #{text}") 64 | is_updated = true 65 | end 66 | is_updated 67 | end 68 | 69 | def update_attachments_from_jira(ticket_id, force=false) 70 | is_updated = false 71 | jira_ticket = self.get_jira_ticket(ticket_id) 72 | attachments = force ? jira_ticket.attachments : jira_ticket.attachments_since(self.last_posting_date) 73 | attachments.each do |attachment| 74 | file = Jira::Client.download(attachment.content) 75 | self.trello_card.add_attachment(file, attachment.filename) 76 | is_updated = true 77 | end 78 | is_updated 79 | end 80 | 81 | def import_content_from_jira(ticket_id) 82 | jira_ticket = self.get_jira_ticket(ticket_id) 83 | 84 | # update card name and description 85 | if jira_ticket.description 86 | description = converted_markup(jira_ticket.description) 87 | description = "** Imported from #{jira_ticket.web_link} **\n\n----\n\n#{description}" 88 | self.trello_card.description = description 89 | end 90 | self.trello_card.name = jira_ticket.summary 91 | self.trello_card.save 92 | 93 | @comments = [] # force a refresh on next request 94 | end 95 | 96 | # Bot Commands 97 | # ------------ 98 | # 99 | # Commands are given in Trello card comments by users. They must mention the bot: 100 | # 101 | # @jirabot track WS-1234 102 | # @jirabot untrack WS-1234 103 | # 104 | def comment_scanner 105 | comments = 106 | self.trello_card.actions(:filter=>'commentCard') 107 | Bot::CommentScanner.new(comments, self.trello_bot.user_id) 108 | end 109 | 110 | def commands 111 | self.comment_scanner.commands 112 | end 113 | 114 | def new_commands 115 | self.comment_scanner.new_commands 116 | end 117 | 118 | def last_posting_date 119 | self.comment_scanner.last_posting_date 120 | end 121 | 122 | def track_jira(ticket_id) 123 | if self.jira_ticket_exists?(ticket_id) 124 | self.jira_tickets.push(ticket_id) 125 | self.jira_tickets.uniq! 126 | end 127 | end 128 | 129 | def untrack_jira(ticket_id) 130 | self.jira_tickets.delete(ticket_id) 131 | end 132 | 133 | def get_jira_ticket(ticket_id) 134 | Jira::Ticket.get(ticket_id) 135 | end 136 | 137 | def jira_ticket_exists?(ticket_id) 138 | Jira::Ticket.exists?(ticket_id) 139 | end 140 | 141 | def update_tracking_status 142 | # update tracking status 143 | self.commands.each do |command| 144 | case command.name 145 | when 'track', 'import' 146 | self.track_jira(command.ticket_id) 147 | when 'untrack' 148 | self.untrack_jira(command.ticket_id) 149 | end 150 | end 151 | end 152 | 153 | def run(command) 154 | case command.name 155 | when 'track' 156 | self.trello_card.add_comment("JIRA ticket #{command.ticket_id} is now being tracked.") 157 | when 'untrack' 158 | self.trello_card.add_comment("JIRA ticket #{command.ticket_id} is no longer being tracked.") 159 | when 'import' 160 | self.import_content_from_jira(command.ticket_id) 161 | self.update_attachments_from_jira(command.ticket_id,true) 162 | self.update_comments_from_jira(command.ticket_id,true) 163 | self.trello_card.add_comment("JIRA ticket #{command.ticket_id} was imported and is being tracked.") 164 | when 'comment' 165 | when 'close' 166 | warn "'#{command.name}' command not implemented" 167 | else 168 | warn "unknown command '#{command.name}'" 169 | end 170 | end 171 | 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jira Tracker for Trello 2 | 3 | ## NOTE: As of 2016-12-10, this project is still installable, and should be usable. However, I am not actively using it. I may not be able to respond to issues and bugs, but I am happy to review and merge pull requests. And if you would like to take over as a maintainer, just file an issue and let me know! -Jeremy 4 | 5 | 6 | ## Trello/Jira Integration 7 | 8 | This project exists to improve workflow between Trello and Jira. It is perfect for an agile team that uses Trello for daily development and sprint planning, but must interact with JIRA for other teams or bug reports. From experience, I have learned it is very hard to track issues in both systems at the same time, and this solution helps. 9 | 10 | A script is run periodically from cron. It interacts with JIRA and Trello using the web APIs. The "bot" is a Trello/Jira user that should have it's own individual account on both systems (we call ours "jirabot"). 11 | 12 | ## Features ## 13 | 14 | Keeps Trello cards up-to-date with referenced JIRA tickets: 15 | 16 | * ticket comments (posted with direct links) 17 | * file attachments (imported directly) 18 | * ticket resolution (as a comment) 19 | 20 | There is also an `import` command, handy for creating new Trello cards from JIRA tickets. 21 | 22 | ## How it works 23 | 24 | The intregration works by assigning the bot to Trello cards and mentioned it in comments. For instance, adding the comment `@jirabot track AX-123` to a Trello card will enable automatic updates from JIRA issue AX-123. 25 | 26 | A script runs periodically, and checks relevant Trello tickets for tracking commands and status. Changes to tracked JIRA tickets (new comments, attachments or ticket resolutions) are posted to the Trello cards as comments. 27 | 28 | ## Screenshot ## 29 | 30 | ![trello test board](https://cloud.githubusercontent.com/assets/7750/21074990/b08242fe-bf07-11e6-8a09-253956e6a9e3.png) 31 | 32 | ## Installation ## 33 | 34 | * Create a Trello user and add it to one or more boards. 35 | * Create a JIRA user and give it access to one or more projects. 36 | * Create a configuration file `config/config.yml`. (See `config/config-example.yml` for details). You will need authentication details to access to the APIs of both Trello and JIRA. 37 | * Install all gems using `bundle install` in the folder of the repository. 38 | * set up a cron job to run the script `bin/trello_jira_bot` periodically (for example, every 10 minutes). On os x run the following command: 39 | `echo “10 * * * * $(pwd)/bin/trello-jira-bot” | crontab` 40 | 41 | ### Authentication Config Notes ## 42 | 43 | How to get the authorization info needed in `config/config.yml`: 44 | 45 | #### JIRA 46 | 47 | Provide a username and password for a "bot" account in JIRA. At the moment this project does one-way sync, so the account can be read-only. This will use the REST API over HTTPS. 48 | 49 | #### Trello 50 | 51 | Create a 'bot' user on Trello, and add it to one or more boards. Trello requires OAuth, using 2-step authentication. Log in as the bot user and generate three pieces of info: 52 | 53 | * secret: the second box from `https://trello.com/1/appKey/generate` 54 | * key: the token you receive in a file downloaded from `https://trello.com/1/authorize?key=substitutewithyourapplicationkey&name=trello-jira-bot&response_type=token&scope=read,write,account&expiration=never` 55 | * public_key: the first box from `https://trello.com/1/appKey/generate` 56 | 57 | ## Usage ## 58 | 59 | A Trello card can be set up to track a set of JIRA tickets. The bot should have access to the board, and be assigned to the ticket. Commands are issued by adding comments to a trello card and mentioning the bot by name: 60 | 61 | @jirabot track SYS-1234 62 | 63 | The bot will respond to with a comment on the ticket: 64 | 65 | SYS-1234 is now being tracked. 66 | 67 | Once tracked, updates to the JIRA ticket will be posted to the Trello card as comments. 68 | 69 | Here are available commands: 70 | 71 | * `track SYS-1536` : JIRAbot will begin tracking updates to this ticket on the given Trello card. 72 | 73 | * `untrack JIRA: SYS-1536` : JIRAbot will stop tracking updates. You can also remove the bot from the card. 74 | 75 | * `import JIRA: SYS-1536` : JIRAbot will import the title, description, ticket link, comments and attachments into the Trello card. This will overwrite any content that was there before. After that, the JIRA ticket will be tracked. 76 | 77 | ## Design ## 78 | 79 | The [ruby-trello](https://github.com/jeremytregunna/ruby-trello) gem is used to communicate with Trello. The [rest-client](https://github.com/archiloque/rest-client) gem and some basic models are used for JIRA. 80 | 81 | ActiveSupport is used to handle configuration, and gain access to things like `.present?`. 82 | 83 | There is no data or state infomation persisted locally. Sync status is determined by looking at event timestamps, and comparing them to when the bot last posted a comment. 84 | 85 | 86 | ## TODO 87 | 88 | * update documentation to be more generic for other configurations 89 | * add some protection so that only authorized Trello organizations can be accessed. Trello has *very* open permissions: anyone can add any use to any board, which could be a security risk otherwise... :/ 90 | 91 | Add more commands: 92 | 93 | * `track SYS-1234, ENG-1536` : handle multiple tickets as arguments 94 | * `comment SYS-1536` : any text on the following lines is appended to the JIRA ticket as a new comment. 95 | * `closes SYS-1536` : closes the given ticket (also support `reopen`, `wontfix`, etc. 96 | * `assign SYS-1234` : assigns the JIRA ticket to the user who added the comment. There would need to be a Trello->Jira user mapping somewhere. 97 | 98 | Maybe the `untrack` command is unneccessary. Removing the command comment, or the bot from the card has the same effect. 99 | 100 | ## Inspirations ## 101 | 102 | * The [ruby-trello](https://github.com/jeremytregunna/ruby-trello) gem by Jeremy Tregunna was a big help in connecting to the Trello API. 103 | * An example hackday project called [devopsdays-checklist](https://github.com/jedi4ever/devopsdays-checklist) was a helpful starting point, especially for Trello authentication. 104 | * The [JIRA API](https://developer.atlassian.com/display/JIRADEV/JIRA+REST+APIs) is surprisingly easy to use, with RESTful paths and predictable (but large) JSON responses. 105 | -------------------------------------------------------------------------------- /spec/fixtures/jira/WS-1080.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "html", 3 | "self": "https://jira.company.com/rest/api/latest/issue/WS-1080", 4 | "key": "WS-1080", 5 | "fields": { 6 | "summary": { 7 | "name": "summary", 8 | "type": "java.lang.String", 9 | "value": "iPad: scroll issues with enlarged map view" 10 | }, 11 | "timetracking": { 12 | "name": "timetracking", 13 | "type": "com.atlassian.jira.issue.fields.TimeTrackingSystemField" 14 | }, 15 | "customfield_10081": { 16 | "name": "Epic/Theme", 17 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:labels" 18 | }, 19 | "customfield_10082": { 20 | "name": "Rank (Obsolete)", 21 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 22 | }, 23 | "issuetype": { 24 | "name": "issuetype", 25 | "type": "com.atlassian.jira.issue.issuetype.IssueType", 26 | "value": { 27 | "self": "https://jira.company.com/rest/api/latest/issueType/17", 28 | "name": "Technical task", 29 | "subtask": true 30 | } 31 | }, 32 | "customfield_10080": { 33 | "name": "Flagged", 34 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes" 35 | }, 36 | "votes": { 37 | "name": "votes", 38 | "type": "com.atlassian.jira.issue.fields.VotesSystemField", 39 | "value": { 40 | "self": "https://jira.company.com/rest/api/latest/issue/WS-1080/votes", 41 | "votes": 0, 42 | "hasVoted": false 43 | } 44 | }, 45 | "fixVersions": { 46 | "name": "fixVersions", 47 | "type": "com.atlassian.jira.project.version.Version", 48 | "value": [ 49 | { 50 | "self": "https://jira.company.com/rest/api/latest/version/11043", 51 | "id": 11043, 52 | "name": "Website", 53 | "archived": false, 54 | "released": false 55 | } 56 | ] 57 | }, 58 | "customfield_10140": { 59 | "name": "ZendeskID", 60 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:readonlyfield" 61 | }, 62 | "reporter": { 63 | "name": "reporter", 64 | "type": "com.opensymphony.user.User", 65 | "value": { 66 | "self": "https://jira.company.com/rest/api/latest/user?username=user65", 67 | "name": "user65", 68 | "displayName": "Hugo Schotman", 69 | "active": true 70 | } 71 | }, 72 | "customfield_10350": { 73 | "name": "Global Rank", 74 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 75 | "value": 2138 76 | }, 77 | "created": { 78 | "name": "created", 79 | "type": "java.util.Date", 80 | "value": "2012-03-23T17:43:21.000+0100" 81 | }, 82 | "updated": { 83 | "name": "updated", 84 | "type": "java.util.Date", 85 | "value": "2012-11-23T17:04:28.000+0100" 86 | }, 87 | "description": { 88 | "name": "description", 89 | "type": "java.lang.String", 90 | "value": "On the iPad, When the map view is expanded:\r\n- There is no hint that there is more content one can scroll to underneath the map.\r\n- It is difficult to scroll because draggin on the map - which takes up most space - will pan the map view.\r\n\r\nSuggestion:\r\nShow the top of the entries in the result list underneath the map.\r\nThat way the user knows that there is more to scroll to and they can tap-and-drag on the visible content underneath the map without much risk of draggin inside the map." 91 | }, 92 | "customfield_10020": { 93 | "name": "Date of First Response", 94 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" 95 | }, 96 | "customfield_10451": { 97 | "name": "Order", 98 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 99 | "value": 9699 100 | }, 101 | "customfield_10450": { 102 | "name": "Rank", 103 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 104 | "value": 6805 105 | }, 106 | "watcher": { 107 | "name": "watcher", 108 | "type": "watcher", 109 | "value": { 110 | "self": "https://jira.company.com/rest/api/latest/issue/WS-1080/watchers", 111 | "isWatching": false, 112 | "watchCount": 0 113 | } 114 | }, 115 | "customfield_10452": { 116 | "name": "Order", 117 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 118 | "value": 8912 119 | }, 120 | "worklog": { 121 | "name": "worklog", 122 | "type": "worklog", 123 | "value": [] 124 | }, 125 | "customfield_10100": { 126 | "name": "Release Version History", 127 | "type": "com.pyxis.greenhopper.jira:greenhopper-releasedmultiversionhistory" 128 | }, 129 | "status": { 130 | "name": "status", 131 | "type": "com.atlassian.jira.issue.status.Status", 132 | "value": { 133 | "self": "https://jira.company.com/rest/api/latest/status/1", 134 | "name": "Open" 135 | } 136 | }, 137 | "customfield_10650": { 138 | "name": "Task Status", 139 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:select" 140 | }, 141 | "customfield_10552": { 142 | "name": "Bonfire Url", 143 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:url" 144 | }, 145 | "parent": { 146 | "name": "parent", 147 | "type": "issuelinks", 148 | "value": { 149 | "issueKey": "WS-1399", 150 | "issue": "https://jira.company.com/rest/api/latest/issue/WS-1399", 151 | "type": { 152 | "name": "Parent", 153 | "direction": "INBOUND" 154 | } 155 | } 156 | }, 157 | "customfield_10551": { 158 | "name": "Zendesk ticket Reference", 159 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 160 | }, 161 | "links": { 162 | "name": "links", 163 | "type": "issuelinks", 164 | "value": [] 165 | }, 166 | "attachment": { 167 | "name": "attachment", 168 | "type": "attachment", 169 | "value": [ 170 | { 171 | "self": "https://jira.company.com/rest/api/latest/attachment/17133", 172 | "filename": "screenshot-1.jpg", 173 | "author": { 174 | "self": "https://jira.company.com/rest/api/latest/user?username=user65", 175 | "name": "user65", 176 | "displayName": "Hugo Schotman", 177 | "active": true 178 | }, 179 | "created": "2012-03-23T17:43:38.000+0100", 180 | "size": 773519, 181 | "mimeType": "image/jpeg", 182 | "content": "https://jira.company.com/secure/attachment/17133/screenshot-1.jpg", 183 | "thumbnail": "https://jira.company.com/secure/thumbnail/17133/_thumb_17133.png" 184 | } 185 | ] 186 | }, 187 | "customfield_10553": { 188 | "name": "Zendesk (company.zendesk.com) ticket Reference", 189 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 190 | }, 191 | "customfield_10550": { 192 | "name": "Zendesk ticket Reference", 193 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 194 | }, 195 | "sub-tasks": { 196 | "name": "sub-tasks", 197 | "type": "issuelinks", 198 | "value": [] 199 | }, 200 | "project": { 201 | "name": "project", 202 | "type": "com.atlassian.jira.project.Project", 203 | "value": { 204 | "self": "https://jira.company.com/rest/api/latest/project/WS", 205 | "key": "WS", 206 | "name": "Web Suche", 207 | "roles": {} 208 | } 209 | }, 210 | "comment": { 211 | "name": "comment", 212 | "type": "com.atlassian.jira.issue.fields.CommentSystemField", 213 | "value": [] 214 | }, 215 | "customfield_10150": { 216 | "name": "Units", 217 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 218 | } 219 | }, 220 | "transitions": "https://jira.company.com/rest/api/latest/issue/WS-1080/transitions" 221 | } 222 | -------------------------------------------------------------------------------- /spec/fixtures/jira/WS-1300.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "html", 3 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1300", 4 | "key": "WS-1300", 5 | "fields": { 6 | "summary": { 7 | "name": "summary", 8 | "type": "java.lang.String", 9 | "value": "map flyout a bit broken" 10 | }, 11 | "timetracking": { 12 | "name": "timetracking", 13 | "type": "com.atlassian.jira.issue.fields.TimeTrackingSystemField" 14 | }, 15 | "customfield_10081": { 16 | "name": "Epic/Theme", 17 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:labels" 18 | }, 19 | "customfield_10082": { 20 | "name": "Rank (Obsolete)", 21 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 22 | }, 23 | "issuetype": { 24 | "name": "issuetype", 25 | "type": "com.atlassian.jira.issue.issuetype.IssueType", 26 | "value": { 27 | "self": "https://jira.example.com/rest/api/latest/issueType/1", 28 | "name": "Bug", 29 | "subtask": false 30 | } 31 | }, 32 | "customfield_10080": { 33 | "name": "Flagged", 34 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes" 35 | }, 36 | "votes": { 37 | "name": "votes", 38 | "type": "com.atlassian.jira.issue.fields.VotesSystemField", 39 | "value": { 40 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1300/votes", 41 | "votes": 0, 42 | "hasVoted": false 43 | } 44 | }, 45 | "fixVersions": { 46 | "name": "fixVersions", 47 | "type": "com.atlassian.jira.project.version.Version", 48 | "value": [ 49 | { 50 | "self": "https://jira.example.com/rest/api/latest/version/11144", 51 | "id": 11144, 52 | "description": "Restaurant Guide", 53 | "name": "Restaurant", 54 | "archived": false, 55 | "released": false 56 | } 57 | ] 58 | }, 59 | "resolution": { 60 | "name": "resolution", 61 | "type": "com.atlassian.jira.issue.resolution.Resolution", 62 | "value": { 63 | "self": "https://jira.example.com/rest/api/latest/resolution/1", 64 | "name": "Fixed" 65 | } 66 | }, 67 | "security": { 68 | "name": "security", 69 | "type": "com.atlassian.jira.issue.security.IssueSecurityLevel" 70 | }, 71 | "resolutiondate": { 72 | "name": "resolutiondate", 73 | "type": "java.util.Date", 74 | "value": "2012-10-31T16:25:04.000+0100" 75 | }, 76 | "customfield_10140": { 77 | "name": "ZendeskID", 78 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:readonlyfield" 79 | }, 80 | "reporter": { 81 | "name": "reporter", 82 | "type": "com.opensymphony.user.User", 83 | "value": { 84 | "self": "https://jira.example.com/rest/api/latest/user?username=user32", 85 | "name": "user32", 86 | "displayName": "user", 87 | "active": true 88 | } 89 | }, 90 | "customfield_10350": { 91 | "name": "Global Rank", 92 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 93 | "value": 4654 94 | }, 95 | "created": { 96 | "name": "created", 97 | "type": "java.util.Date", 98 | "value": "2012-10-02T15:20:35.000+0200" 99 | }, 100 | "updated": { 101 | "name": "updated", 102 | "type": "java.util.Date", 103 | "value": "2012-11-26T11:41:18.000+0100" 104 | }, 105 | "priority": { 106 | "name": "priority", 107 | "type": "com.atlassian.jira.issue.priority.Priority", 108 | "value": { 109 | "self": "https://jira.example.com/rest/api/latest/priority/4", 110 | "name": "Minor" 111 | } 112 | }, 113 | "description": { 114 | "name": "description", 115 | "type": "java.lang.String", 116 | "value": "http://screencast.com/t/bj59PYsBfN" 117 | }, 118 | "duedate": { 119 | "name": "duedate", 120 | "type": "java.util.Date" 121 | }, 122 | "customfield_10020": { 123 | "name": "Date of First Response", 124 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" 125 | }, 126 | "customfield_10451": { 127 | "name": "Order", 128 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 129 | "value": 12239 130 | }, 131 | "customfield_10450": { 132 | "name": "Rank", 133 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 134 | "value": 8913 135 | }, 136 | "watcher": { 137 | "name": "watcher", 138 | "type": "watcher", 139 | "value": { 140 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1300/watchers", 141 | "isWatching": false, 142 | "watchCount": 0 143 | } 144 | }, 145 | "customfield_10452": { 146 | "name": "Order", 147 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 148 | "value": 11332 149 | }, 150 | "worklog": { 151 | "name": "worklog", 152 | "type": "worklog", 153 | "value": [] 154 | }, 155 | "customfield_10100": { 156 | "name": "Release Version History", 157 | "type": "com.pyxis.greenhopper.jira:greenhopper-releasedmultiversionhistory" 158 | }, 159 | "customfield_10650": { 160 | "name": "Task Status", 161 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:select", 162 | "value": { 163 | "self": "https://jira.example.com/rest/api/latest/customFieldOption/10040", 164 | "value": "Analysis" 165 | } 166 | }, 167 | "status": { 168 | "name": "status", 169 | "type": "com.atlassian.jira.issue.status.Status", 170 | "value": { 171 | "self": "https://jira.example.com/rest/api/latest/status/6", 172 | "name": "Closed" 173 | } 174 | }, 175 | "labels": { 176 | "name": "labels", 177 | "type": "com.atlassian.jira.issue.label.Label", 178 | "value": [] 179 | }, 180 | "customfield_10552": { 181 | "name": "Bonfire Url", 182 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:url" 183 | }, 184 | "customfield_10551": { 185 | "name": "Zendesk ticket Reference", 186 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 187 | }, 188 | "assignee": { 189 | "name": "assignee", 190 | "type": "com.opensymphony.user.User" 191 | }, 192 | "links": { 193 | "name": "links", 194 | "type": "issuelinks", 195 | "value": [] 196 | }, 197 | "attachment": { 198 | "name": "attachment", 199 | "type": "attachment", 200 | "value": [] 201 | }, 202 | "customfield_10553": { 203 | "name": "Zendesk (company.zendesk.com) ticket Reference", 204 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 205 | }, 206 | "customfield_10550": { 207 | "name": "Zendesk ticket Reference", 208 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 209 | }, 210 | "sub-tasks": { 211 | "name": "sub-tasks", 212 | "type": "issuelinks", 213 | "value": [] 214 | }, 215 | "project": { 216 | "name": "project", 217 | "type": "com.atlassian.jira.project.Project", 218 | "value": { 219 | "self": "https://jira.example.com/rest/api/latest/project/WS", 220 | "key": "WS", 221 | "name": "Web Suche", 222 | "roles": {} 223 | } 224 | }, 225 | "environment": { 226 | "name": "environment", 227 | "type": "java.lang.String" 228 | }, 229 | "customfield_10053": { 230 | "name": "Story Points (old)", 231 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 232 | }, 233 | "components": { 234 | "name": "components", 235 | "type": "com.atlassian.jira.bc.project.component.ProjectComponent", 236 | "value": [] 237 | }, 238 | "comment": { 239 | "name": "comment", 240 | "type": "com.atlassian.jira.issue.fields.CommentSystemField", 241 | "value": [ 242 | { 243 | "self": "https://jira.example.com/rest/api/latest/comment/77951", 244 | "author": { 245 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 246 | "name": "user56", 247 | "displayName": "Georg Kunz", 248 | "active": true 249 | }, 250 | "body": "Fixed by hiding overlapping text", 251 | "updateAuthor": { 252 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 253 | "name": "user56", 254 | "displayName": "Georg Kunz", 255 | "active": true 256 | }, 257 | "created": "2012-10-31T16:25:04.000+0100", 258 | "updated": "2012-10-31T16:25:04.000+0100" 259 | } 260 | ] 261 | }, 262 | "customfield_10150": { 263 | "name": "Units", 264 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 265 | } 266 | }, 267 | "transitions": "https://jira.example.com/rest/api/latest/issue/WS-1300/transitions" 268 | } 269 | -------------------------------------------------------------------------------- /spec/fixtures/jira/WS-1299.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "html", 3 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1299", 4 | "key": "WS-1299", 5 | "fields": { 6 | "summary": { 7 | "name": "summary", 8 | "type": "java.lang.String", 9 | "value": "wrong slots after calculating a route" 10 | }, 11 | "timetracking": { 12 | "name": "timetracking", 13 | "type": "com.atlassian.jira.issue.fields.TimeTrackingSystemField" 14 | }, 15 | "customfield_10081": { 16 | "name": "Epic/Theme", 17 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:labels" 18 | }, 19 | "customfield_10082": { 20 | "name": "Rank (Obsolete)", 21 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 22 | }, 23 | "issuetype": { 24 | "name": "issuetype", 25 | "type": "com.atlassian.jira.issue.issuetype.IssueType", 26 | "value": { 27 | "self": "https://jira.example.com/rest/api/latest/issueType/1", 28 | "name": "Bug", 29 | "subtask": false 30 | } 31 | }, 32 | "customfield_10080": { 33 | "name": "Flagged", 34 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes" 35 | }, 36 | "votes": { 37 | "name": "votes", 38 | "type": "com.atlassian.jira.issue.fields.VotesSystemField", 39 | "value": { 40 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1299/votes", 41 | "votes": 0, 42 | "hasVoted": false 43 | } 44 | }, 45 | "fixVersions": { 46 | "name": "fixVersions", 47 | "type": "com.atlassian.jira.project.version.Version", 48 | "value": [ 49 | { 50 | "self": "https://jira.example.com/rest/api/latest/version/11144", 51 | "id": 11144, 52 | "description": "Restaurant Guide", 53 | "name": "Restaurant", 54 | "archived": false, 55 | "released": false 56 | } 57 | ] 58 | }, 59 | "resolution": { 60 | "name": "resolution", 61 | "type": "com.atlassian.jira.issue.resolution.Resolution", 62 | "value": { 63 | "self": "https://jira.example.com/rest/api/latest/resolution/3", 64 | "name": "Duplicate" 65 | } 66 | }, 67 | "security": { 68 | "name": "security", 69 | "type": "com.atlassian.jira.issue.security.IssueSecurityLevel" 70 | }, 71 | "resolutiondate": { 72 | "name": "resolutiondate", 73 | "type": "java.util.Date", 74 | "value": "2012-10-24T17:13:44.000+0200" 75 | }, 76 | "customfield_10140": { 77 | "name": "ZendeskID", 78 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:readonlyfield" 79 | }, 80 | "reporter": { 81 | "name": "reporter", 82 | "type": "com.opensymphony.user.User", 83 | "value": { 84 | "self": "https://jira.example.com/rest/api/latest/user?username=user04", 85 | "name": "user04", 86 | "displayName": "Stephan Schär", 87 | "active": true 88 | } 89 | }, 90 | "customfield_10350": { 91 | "name": "Global Rank", 92 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 93 | "value": 4653 94 | }, 95 | "created": { 96 | "name": "created", 97 | "type": "java.util.Date", 98 | "value": "2012-10-02T15:18:40.000+0200" 99 | }, 100 | "updated": { 101 | "name": "updated", 102 | "type": "java.util.Date", 103 | "value": "2012-10-24T17:13:44.000+0200" 104 | }, 105 | "priority": { 106 | "name": "priority", 107 | "type": "com.atlassian.jira.issue.priority.Priority", 108 | "value": { 109 | "self": "https://jira.example.com/rest/api/latest/priority/4", 110 | "name": "Minor" 111 | } 112 | }, 113 | "description": { 114 | "name": "description", 115 | "type": "java.lang.String", 116 | "value": "http://screencast.com/t/vQrtGmYLxC" 117 | }, 118 | "duedate": { 119 | "name": "duedate", 120 | "type": "java.util.Date" 121 | }, 122 | "customfield_10020": { 123 | "name": "Date of First Response", 124 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" 125 | }, 126 | "customfield_10451": { 127 | "name": "Order", 128 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 129 | "value": 12238 130 | }, 131 | "customfield_10450": { 132 | "name": "Rank", 133 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 134 | "value": 8912 135 | }, 136 | "watcher": { 137 | "name": "watcher", 138 | "type": "watcher", 139 | "value": { 140 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1299/watchers", 141 | "isWatching": false, 142 | "watchCount": 0 143 | } 144 | }, 145 | "customfield_10452": { 146 | "name": "Order", 147 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 148 | "value": 11331 149 | }, 150 | "worklog": { 151 | "name": "worklog", 152 | "type": "worklog", 153 | "value": [] 154 | }, 155 | "customfield_10100": { 156 | "name": "Release Version History", 157 | "type": "com.pyxis.greenhopper.jira:greenhopper-releasedmultiversionhistory" 158 | }, 159 | "customfield_10650": { 160 | "name": "Task Status", 161 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:select", 162 | "value": { 163 | "self": "https://jira.example.com/rest/api/latest/customFieldOption/10040", 164 | "value": "Analysis" 165 | } 166 | }, 167 | "status": { 168 | "name": "status", 169 | "type": "com.atlassian.jira.issue.status.Status", 170 | "value": { 171 | "self": "https://jira.example.com/rest/api/latest/status/6", 172 | "name": "Closed" 173 | } 174 | }, 175 | "labels": { 176 | "name": "labels", 177 | "type": "com.atlassian.jira.issue.label.Label", 178 | "value": [] 179 | }, 180 | "customfield_10552": { 181 | "name": "Bonfire Url", 182 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:url" 183 | }, 184 | "customfield_10551": { 185 | "name": "Zendesk ticket Reference", 186 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 187 | }, 188 | "assignee": { 189 | "name": "assignee", 190 | "type": "com.opensymphony.user.User" 191 | }, 192 | "links": { 193 | "name": "links", 194 | "type": "issuelinks", 195 | "value": [] 196 | }, 197 | "attachment": { 198 | "name": "attachment", 199 | "type": "attachment", 200 | "value": [] 201 | }, 202 | "customfield_10553": { 203 | "name": "Zendesk (company.zendesk.com) ticket Reference", 204 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 205 | }, 206 | "customfield_10550": { 207 | "name": "Zendesk ticket Reference", 208 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 209 | }, 210 | "sub-tasks": { 211 | "name": "sub-tasks", 212 | "type": "issuelinks", 213 | "value": [] 214 | }, 215 | "project": { 216 | "name": "project", 217 | "type": "com.atlassian.jira.project.Project", 218 | "value": { 219 | "self": "https://jira.example.com/rest/api/latest/project/WS", 220 | "key": "WS", 221 | "name": "Web Suche", 222 | "roles": {} 223 | } 224 | }, 225 | "environment": { 226 | "name": "environment", 227 | "type": "java.lang.String" 228 | }, 229 | "customfield_10053": { 230 | "name": "Story Points (old)", 231 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 232 | }, 233 | "components": { 234 | "name": "components", 235 | "type": "com.atlassian.jira.bc.project.component.ProjectComponent", 236 | "value": [] 237 | }, 238 | "comment": { 239 | "name": "comment", 240 | "type": "com.atlassian.jira.issue.fields.CommentSystemField", 241 | "value": [ 242 | { 243 | "self": "https://jira.example.com/rest/api/latest/comment/76787", 244 | "author": { 245 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 246 | "name": "user56", 247 | "displayName": "Georg Kunz", 248 | "active": true 249 | }, 250 | "body": "duplicate of WS-1260", 251 | "updateAuthor": { 252 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 253 | "name": "user56", 254 | "displayName": "Georg Kunz", 255 | "active": true 256 | }, 257 | "created": "2012-10-24T17:13:44.000+0200", 258 | "updated": "2012-10-24T17:13:44.000+0200" 259 | } 260 | ] 261 | }, 262 | "customfield_10150": { 263 | "name": "Units", 264 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 265 | } 266 | }, 267 | "transitions": "https://jira.example.com/rest/api/latest/issue/WS-1299/transitions" 268 | } 269 | -------------------------------------------------------------------------------- /spec/fixtures/jira/WS-1230.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "html", 3 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1230", 4 | "key": "WS-1230", 5 | "fields": { 6 | "summary": { 7 | "name": "summary", 8 | "type": "java.lang.String", 9 | "value": "Adress mismatch on map.company.com" 10 | }, 11 | "timetracking": { 12 | "name": "timetracking", 13 | "type": "com.atlassian.jira.issue.fields.TimeTrackingSystemField" 14 | }, 15 | "customfield_10081": { 16 | "name": "Epic/Theme", 17 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:labels" 18 | }, 19 | "customfield_10082": { 20 | "name": "Rank (Obsolete)", 21 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 22 | }, 23 | "issuetype": { 24 | "name": "issuetype", 25 | "type": "com.atlassian.jira.issue.issuetype.IssueType", 26 | "value": { 27 | "self": "https://jira.example.com/rest/api/latest/issueType/1", 28 | "name": "Bug", 29 | "subtask": false 30 | } 31 | }, 32 | "customfield_10080": { 33 | "name": "Flagged", 34 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes" 35 | }, 36 | "votes": { 37 | "name": "votes", 38 | "type": "com.atlassian.jira.issue.fields.VotesSystemField", 39 | "value": { 40 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1230/votes", 41 | "votes": 0, 42 | "hasVoted": false 43 | } 44 | }, 45 | "fixVersions": { 46 | "name": "fixVersions", 47 | "type": "com.atlassian.jira.project.version.Version", 48 | "value": [ 49 | { 50 | "self": "https://jira.example.com/rest/api/latest/version/11043", 51 | "id": 11043, 52 | "name": "Website", 53 | "archived": false, 54 | "released": false 55 | } 56 | ] 57 | }, 58 | "security": { 59 | "name": "security", 60 | "type": "com.atlassian.jira.issue.security.IssueSecurityLevel" 61 | }, 62 | "customfield_10140": { 63 | "name": "ZendeskID", 64 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:readonlyfield", 65 | "value": "28420" 66 | }, 67 | "reporter": { 68 | "name": "reporter", 69 | "type": "com.opensymphony.user.User", 70 | "value": { 71 | "self": "https://jira.example.com/rest/api/latest/user?username=automator", 72 | "name": "automator", 73 | "displayName": "Otto Mätor", 74 | "active": true 75 | } 76 | }, 77 | "customfield_10350": { 78 | "name": "Global Rank", 79 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 80 | "value": 4407 81 | }, 82 | "created": { 83 | "name": "created", 84 | "type": "java.util.Date", 85 | "value": "2012-09-13T12:09:25.000+0200" 86 | }, 87 | "updated": { 88 | "name": "updated", 89 | "type": "java.util.Date", 90 | "value": "2012-11-28T17:57:57.000+0100" 91 | }, 92 | "priority": { 93 | "name": "priority", 94 | "type": "com.atlassian.jira.issue.priority.Priority", 95 | "value": { 96 | "self": "https://jira.example.com/rest/api/latest/priority/4", 97 | "name": "Minor" 98 | } 99 | }, 100 | "description": { 101 | "name": "description", 102 | "type": "java.lang.String", 103 | "value": "On http://map.company.com/de/search/Physiotherapie%20in%20der%20N%C3%A4he%20Z%C3%BCrich?page=6 this adress is wrong:\r\nBereschnaja Tatjana (856 m)\r\nBahnhofstrasse Zürich Hauptbahnhof 87, 8001 Zürich\r\nTelefon 076 345 37 07\r\n\r\nOn http://yellow.company.com/de/d/Zuerich/8001/Physiotherapie/Bereschnaja-Tatjana-g2TjwxSriNlDG-xO7zdXCw?what=Physiotherapie&where=Z%C3%BCrich you find the correct one:\r\nBereschnaja Tatjana\r\nPhysiotherapie Rehabilitation\r\nPhysiotherapie und Rehabilitation\r\nBahnhofstrasse 87 Zürich Hauptbahnhof\r\n8001 Zürich\r\n\r\nAs you can see, the adress should be \"Bahnhofstrasse 87\" and not \"Hauptbahnhof 87\".\r\nBy the way: Why is there \"Hauptbahnhof\", \"Bahnhofstrasse 87\" is not within the \"Hauptbahnhof\".\r\n\r\nThomas Thut" 104 | }, 105 | "duedate": { 106 | "name": "duedate", 107 | "type": "java.util.Date" 108 | }, 109 | "customfield_10020": { 110 | "name": "Date of First Response", 111 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" 112 | }, 113 | "customfield_10451": { 114 | "name": "Order", 115 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 116 | "value": 11990 117 | }, 118 | "customfield_10450": { 119 | "name": "Rank", 120 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 121 | "value": 8666 122 | }, 123 | "watcher": { 124 | "name": "watcher", 125 | "type": "watcher", 126 | "value": { 127 | "self": "https://jira.example.com/rest/api/latest/issue/WS-1230/watchers", 128 | "isWatching": false, 129 | "watchCount": 0 130 | } 131 | }, 132 | "customfield_10452": { 133 | "name": "Order", 134 | "type": "com.pyxis.greenhopper.jira:gh-global-rank", 135 | "value": 11085 136 | }, 137 | "worklog": { 138 | "name": "worklog", 139 | "type": "worklog", 140 | "value": [] 141 | }, 142 | "customfield_10100": { 143 | "name": "Release Version History", 144 | "type": "com.pyxis.greenhopper.jira:greenhopper-releasedmultiversionhistory" 145 | }, 146 | "customfield_10650": { 147 | "name": "Task Status", 148 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:select", 149 | "value": { 150 | "self": "https://jira.example.com/rest/api/latest/customFieldOption/10040", 151 | "value": "Analysis" 152 | } 153 | }, 154 | "status": { 155 | "name": "status", 156 | "type": "com.atlassian.jira.issue.status.Status", 157 | "value": { 158 | "self": "https://jira.example.com/rest/api/latest/status/1", 159 | "name": "Open" 160 | } 161 | }, 162 | "labels": { 163 | "name": "labels", 164 | "type": "com.atlassian.jira.issue.label.Label", 165 | "value": [] 166 | }, 167 | "customfield_10552": { 168 | "name": "Bonfire Url", 169 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:url" 170 | }, 171 | "customfield_10551": { 172 | "name": "Zendesk ticket Reference", 173 | "type": "net.customware.plugins.connector.atlassian.jira-connector-plugin:remotesystem-id-customfield" 174 | }, 175 | "assignee": { 176 | "name": "assignee", 177 | "type": "com.opensymphony.user.User" 178 | }, 179 | "links": { 180 | "name": "links", 181 | "type": "issuelinks", 182 | "value": [] 183 | }, 184 | "attachment": { 185 | "name": "attachment", 186 | "type": "attachment", 187 | "value": [] 188 | }, 189 | "sub-tasks": { 190 | "name": "sub-tasks", 191 | "type": "issuelinks", 192 | "value": [] 193 | }, 194 | "project": { 195 | "name": "project", 196 | "type": "com.atlassian.jira.project.Project", 197 | "value": { 198 | "self": "https://jira.example.com/rest/api/latest/project/WS", 199 | "key": "WS", 200 | "name": "Web Suche", 201 | "roles": {} 202 | } 203 | }, 204 | "environment": { 205 | "name": "environment", 206 | "type": "java.lang.String" 207 | }, 208 | "customfield_10053": { 209 | "name": "Story Points (old)", 210 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 211 | }, 212 | "components": { 213 | "name": "components", 214 | "type": "com.atlassian.jira.bc.project.component.ProjectComponent", 215 | "value": [] 216 | }, 217 | "comment": { 218 | "name": "comment", 219 | "type": "com.atlassian.jira.issue.fields.CommentSystemField", 220 | "value": [ 221 | { 222 | "self": "https://jira.example.com/rest/api/latest/comment/74940", 223 | "author": { 224 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 225 | "name": "user56", 226 | "displayName": "Georg Kunz", 227 | "active": true 228 | }, 229 | "body": "As discussed this is a bug on map.company.com and will be fixed within our map improvements which happen in mid October.", 230 | "updateAuthor": { 231 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 232 | "name": "user56", 233 | "displayName": "Georg Kunz", 234 | "active": true 235 | }, 236 | "created": "2012-09-13T14:56:35.000+0200", 237 | "updated": "2012-09-13T14:56:35.000+0200" 238 | }, 239 | { 240 | "self": "https://jira.example.com/rest/api/latest/comment/76909", 241 | "author": { 242 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 243 | "name": "user56", 244 | "displayName": "Georg Kunz", 245 | "active": true 246 | }, 247 | "body": "Map fixes are delayed a bit. We start working on it early november.", 248 | "updateAuthor": { 249 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 250 | "name": "user56", 251 | "displayName": "Georg Kunz", 252 | "active": true 253 | }, 254 | "created": "2012-10-25T10:24:50.000+0200", 255 | "updated": "2012-10-25T10:24:50.000+0200" 256 | }, 257 | { 258 | "self": "https://jira.example.com/rest/api/latest/comment/78690", 259 | "author": { 260 | "self": "https://jira.example.com/rest/api/latest/user?username=user90", 261 | "name": "user90", 262 | "displayName": "Thomas Thut", 263 | "active": true 264 | }, 265 | "body": "http://map.company.com/de/search/Physiotherapie%20in%20der%20N%C3%A4he%20Z%C3%BCrich?page=6 seams to be fixed: http://screencast.com/t/0co8lwSdwcP\r\n\r\nMagic or forgotten comment?", 266 | "updateAuthor": { 267 | "self": "https://jira.example.com/rest/api/latest/user?username=user90", 268 | "name": "user90", 269 | "displayName": "Thomas Thut", 270 | "active": true 271 | }, 272 | "created": "2012-11-15T11:53:08.000+0100", 273 | "updated": "2012-11-15T11:53:08.000+0100" 274 | }, 275 | { 276 | "self": "https://jira.example.com/rest/api/latest/comment/78697", 277 | "author": { 278 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 279 | "name": "user56", 280 | "displayName": "Georg Kunz", 281 | "active": true 282 | }, 283 | "body": "Data change: bahnhof was removed but bug is still open.", 284 | "updateAuthor": { 285 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 286 | "name": "user56", 287 | "displayName": "Georg Kunz", 288 | "active": true 289 | }, 290 | "created": "2012-11-15T14:34:23.000+0100", 291 | "updated": "2012-11-15T14:34:23.000+0100" 292 | }, 293 | { 294 | "self": "https://jira.example.com/rest/api/latest/comment/79250", 295 | "author": { 296 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 297 | "name": "user56", 298 | "displayName": "Georg Kunz", 299 | "active": true 300 | }, 301 | "body": "https://trello.com/c/mcTpgv4k", 302 | "updateAuthor": { 303 | "self": "https://jira.example.com/rest/api/latest/user?username=user56", 304 | "name": "user56", 305 | "displayName": "Georg Kunz", 306 | "active": true 307 | }, 308 | "created": "2012-11-28T17:57:57.000+0100", 309 | "updated": "2012-11-28T17:57:57.000+0100" 310 | } 311 | ] 312 | }, 313 | "customfield_10150": { 314 | "name": "Units", 315 | "type": "com.atlassian.jira.plugin.system.customfieldtypes:float" 316 | } 317 | }, 318 | "transitions": "https://jira.example.com/rest/api/latest/issue/WS-1230/transitions" 319 | } 320 | -------------------------------------------------------------------------------- /spec/fixtures/jira/SYS-1916.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "renderedFields,names,schema,transitions,operations,editmeta,changelog", 3 | "id": "35317", 4 | "self": "https://jira.company.com/rest/api/latest/issue/35317", 5 | "key": "SYS-1916", 6 | "fields": { 7 | "summary": "Change redirect for mobile devices", 8 | "progress": { 9 | "progress": 0, 10 | "total": 0 11 | }, 12 | "issuetype": { 13 | "self": "https://jira.company.com/rest/api/2/issuetype/25", 14 | "id": "25", 15 | "description": "", 16 | "iconUrl": "https://jira.company.com/images/icons/issuetypes/bug.png", 17 | "name": "Operations", 18 | "subtask": false 19 | }, 20 | "votes": { 21 | "self": "https://jira.company.com/rest/api/2/issue/SYS-1916/votes", 22 | "votes": 0, 23 | "hasVoted": false 24 | }, 25 | "resolution": { 26 | "self": "https://jira.company.com/rest/api/2/resolution/1", 27 | "id": "1", 28 | "description": "A fix for this issue is checked into the tree and tested.", 29 | "name": "Fixed" 30 | }, 31 | "resolutiondate": "2012-10-01T17:38:08.000+0200", 32 | "timespent": null, 33 | "reporter": { 34 | "self": "https://jira.company.com/rest/api/2/user?username=user90", 35 | "name": "user90", 36 | "emailAddress": "Thomas.Thut@company.com", 37 | "avatarUrls": { 38 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 39 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 40 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 41 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 42 | }, 43 | "displayName": "Thomas Thut", 44 | "active": true 45 | }, 46 | "aggregatetimeoriginalestimate": null, 47 | "updated": "2013-01-04T11:03:48.000+0100", 48 | "created": "2012-08-07T09:53:22.000+0200", 49 | "description": "Current situation: (lb: company.com main website dispatcher)\n\nRedirect to http://mobile.company.com (strip away existing urls)\n\nRequested situation:\n\nRedirect to http://mobile.{whatitwasbefore}/{original-url}\n", 50 | "priority": { 51 | "self": "https://jira.company.com/rest/api/2/priority/4", 52 | "iconUrl": "https://jira.company.com/images/icons/priorities/minor.png", 53 | "name": "Minor", 54 | "id": "4" 55 | }, 56 | "duedate": null, 57 | "issuelinks": [], 58 | "customfield_10450": "7891", 59 | "watches": { 60 | "self": "https://jira.company.com/rest/api/2/issue/SYS-1916/watchers", 61 | "watchCount": 6, 62 | "isWatching": false 63 | }, 64 | "customfield_10751": null, 65 | "subtasks": [], 66 | "customfield_10100": null, 67 | "customfield_10750": null, 68 | "status": { 69 | "self": "https://jira.company.com/rest/api/2/status/6", 70 | "description": "The issue is considered finished, the resolution is correct. Issues which are closed can be reopened.", 71 | "iconUrl": "https://jira.company.com/images/icons/statuses/closed.png", 72 | "name": "Closed", 73 | "id": "6" 74 | }, 75 | "customfield_10091": null, 76 | "workratio": -1, 77 | "customfield_10551": null, 78 | "assignee": { 79 | "self": "https://jira.company.com/rest/api/2/user?username=lcl50101", 80 | "name": "lcl50101", 81 | "emailAddress": "user@company.com", 82 | "avatarUrls": { 83 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 84 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 85 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 86 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 87 | }, 88 | "displayName": "user", 89 | "active": true 90 | }, 91 | "customfield_10553": null, 92 | "customfield_10250": null, 93 | "customfield_10550": null, 94 | "customfield_10251": null, 95 | "aggregatetimeestimate": null, 96 | "project": { 97 | "self": "https://jira.company.com/rest/api/2/project/SYS", 98 | "id": "10145", 99 | "key": "SYS", 100 | "name": "System Engineering", 101 | "avatarUrls": { 102 | "16x16": "https://jira.company.com/secure/projectavatar?size=xsmall&pid=10145&avatarId=10010", 103 | "24x24": "https://jira.company.com/secure/projectavatar?size=small&pid=10145&avatarId=10010", 104 | "32x32": "https://jira.company.com/secure/projectavatar?size=medium&pid=10145&avatarId=10010", 105 | "48x48": "https://jira.company.com/secure/projectavatar?pid=10145&avatarId=10010" 106 | } 107 | }, 108 | "timeestimate": null, 109 | "aggregateprogress": { 110 | "progress": 0, 111 | "total": 0 112 | }, 113 | "lastViewed": null, 114 | "components": [], 115 | "comment": { 116 | "startAt": 0, 117 | "maxResults": 8, 118 | "total": 8, 119 | "comments": [ 120 | { 121 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73566", 122 | "id": "73566", 123 | "author": { 124 | "self": "https://jira.company.com/rest/api/2/user?username=user123", 125 | "name": "user123", 126 | "emailAddress": "user@company.com", 127 | "avatarUrls": { 128 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 129 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 130 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 131 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 132 | }, 133 | "displayName": "user", 134 | "active": true 135 | }, 136 | "body": "user's view:\n\n* With mobile device: Result on google => goes to main website instead of search result (BAD -> agreed :-)\n* Proposal: Use mobile pool directly, no rewrite, mobile team rewrites themselves.\n\n", 137 | "updateAuthor": { 138 | "self": "https://jira.company.com/rest/api/2/user?username=user85", 139 | "name": "user85", 140 | "emailAddress": "user.name@company.com", 141 | "avatarUrls": { 142 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 143 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 144 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 145 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 146 | }, 147 | "displayName": "user", 148 | "active": true 149 | }, 150 | "created": "2012-08-07T10:05:53.000+0200", 151 | "updated": "2012-08-07T10:05:53.000+0200" 152 | }, 153 | { 154 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73567", 155 | "id": "73567", 156 | "author": { 157 | "self": "https://jira.company.com/rest/api/2/user?username=user85", 158 | "name": "user85", 159 | "emailAddress": "user.name@company.com", 160 | "avatarUrls": { 161 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 162 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 163 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 164 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 165 | }, 166 | "displayName": "user", 167 | "active": true 168 | }, 169 | "body": "I've collected information for you, but I'd be happy if you can solve this issue, so that I don't do a big change before moving away.", 170 | "updateAuthor": { 171 | "self": "https://jira.company.com/rest/api/2/user?username=user85", 172 | "name": "user85", 173 | "emailAddress": "user.name@company.com", 174 | "avatarUrls": { 175 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 176 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 177 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 178 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 179 | }, 180 | "displayName": "user", 181 | "active": true 182 | }, 183 | "created": "2012-08-07T10:50:46.000+0200", 184 | "updated": "2012-08-07T10:50:46.000+0200" 185 | }, 186 | { 187 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73568", 188 | "id": "73568", 189 | "author": { 190 | "self": "https://jira.company.com/rest/api/2/user?username=user85", 191 | "name": "user85", 192 | "emailAddress": "user.name@company.com", 193 | "avatarUrls": { 194 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 195 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 196 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 197 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 198 | }, 199 | "displayName": "user", 200 | "active": true 201 | }, 202 | "body": "user:\n\nWhen accessing company.com with mobile, either redirect to mobile.company.com (including rest of the URL) OR\ndirectly use the mobile pool (pool.use(\"mobile-...\") in LB).\n\n\n\n", 203 | "updateAuthor": { 204 | "self": "https://jira.company.com/rest/api/2/user?username=user85", 205 | "name": "user85", 206 | "emailAddress": "user.name@company.com", 207 | "avatarUrls": { 208 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 209 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 210 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 211 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 212 | }, 213 | "displayName": "user", 214 | "active": true 215 | }, 216 | "created": "2012-08-07T10:53:57.000+0200", 217 | "updated": "2012-08-07T10:53:57.000+0200" 218 | }, 219 | { 220 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73569", 221 | "id": "73569", 222 | "author": { 223 | "self": "https://jira.company.com/rest/api/2/user?username=user85", 224 | "name": "user85", 225 | "emailAddress": "user.name@company.com", 226 | "avatarUrls": { 227 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 228 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 229 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 230 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 231 | }, 232 | "displayName": "user", 233 | "active": true 234 | }, 235 | "body": "Proposal: Meet tomorrow (2012-08-08) with user, , user.name and change the load balancer rule \"company.com main website dispatcher\".\n\n\n", 236 | "updateAuthor": { 237 | "self": "https://jira.company.com/rest/api/2/user?username=user85", 238 | "name": "user85", 239 | "emailAddress": "user.name@company.com", 240 | "avatarUrls": { 241 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 242 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 243 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 244 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 245 | }, 246 | "displayName": "user", 247 | "active": true 248 | }, 249 | "created": "2012-08-07T10:55:25.000+0200", 250 | "updated": "2012-08-07T10:55:25.000+0200" 251 | }, 252 | { 253 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73574", 254 | "id": "73574", 255 | "author": { 256 | "self": "https://jira.company.com/rest/api/2/user?username=user33", 257 | "name": "user33", 258 | "emailAddress": "user@company.com", 259 | "avatarUrls": { 260 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 261 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 262 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 263 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 264 | }, 265 | "displayName": "user", 266 | "active": true 267 | }, 268 | "body": "When the user click on 'Classic' button on m.company.com (i.company.com), we set the cookie 'mobile' with value 'no' .\n\nDomain '.company.com', Expiry: time() + 3 months\n\n", 269 | "updateAuthor": { 270 | "self": "https://jira.company.com/rest/api/2/user?username=user33", 271 | "name": "user33", 272 | "emailAddress": "user@company.com", 273 | "avatarUrls": { 274 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 275 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 276 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 277 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 278 | }, 279 | "displayName": "user", 280 | "active": true 281 | }, 282 | "created": "2012-08-07T16:40:24.000+0200", 283 | "updated": "2012-08-07T16:40:24.000+0200" 284 | }, 285 | { 286 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73575", 287 | "id": "73575", 288 | "author": { 289 | "self": "https://jira.company.com/rest/api/2/user?username=user33", 290 | "name": "user33", 291 | "emailAddress": "user@company.com", 292 | "avatarUrls": { 293 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 294 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 295 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 296 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 297 | }, 298 | "displayName": "user", 299 | "active": true 300 | }, 301 | "body": "To save a meeting, here is a quick action plan business logic and handle there the deep linking", 302 | "updateAuthor": { 303 | "self": "https://jira.company.com/rest/api/2/user?username=user33", 304 | "name": "user33", 305 | "emailAddress": "user@company.com", 306 | "avatarUrls": { 307 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 308 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 309 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 310 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 311 | }, 312 | "displayName": "user", 313 | "active": true 314 | }, 315 | "created": "2012-08-07T17:25:10.000+0200", 316 | "updated": "2012-08-08T17:23:07.000+0200" 317 | }, 318 | { 319 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73601", 320 | "id": "73601", 321 | "author": { 322 | "self": "https://jira.company.com/rest/api/2/user?username=lcl50101", 323 | "name": "lcl50101", 324 | "emailAddress": "user@company.com", 325 | "avatarUrls": { 326 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 327 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 328 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 329 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 330 | }, 331 | "displayName": "user", 332 | "active": true 333 | }, 334 | "body": "Mobile Team (Vasile) has set up new Virtual Servers on Apache and made some code changes to handle this request.\nLoadBalancer rule (company.com main website dispatcher) amended to use the pool directly for mobile clients. Cookie check also amended to be more informative.", 335 | "updateAuthor": { 336 | "self": "https://jira.company.com/rest/api/2/user?username=lcl50101", 337 | "name": "lcl50101", 338 | "emailAddress": "user@company.com", 339 | "avatarUrls": { 340 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 341 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 342 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 343 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 344 | }, 345 | "displayName": "user", 346 | "active": true 347 | }, 348 | "created": "2012-08-09T14:10:30.000+0200", 349 | "updated": "2012-08-09T14:35:08.000+0200" 350 | }, 351 | { 352 | "self": "https://jira.company.com/rest/api/2/issue/35317/comment/73602", 353 | "id": "73602", 354 | "author": { 355 | "self": "https://jira.company.com/rest/api/2/user?username=lcl50101", 356 | "name": "lcl50101", 357 | "emailAddress": "user@company.com", 358 | "avatarUrls": { 359 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 360 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 361 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 362 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 363 | }, 364 | "displayName": "user", 365 | "active": true 366 | }, 367 | "body": "Waiting for mid and long term steps to be implemented.", 368 | "updateAuthor": { 369 | "self": "https://jira.company.com/rest/api/2/user?username=lcl50101", 370 | "name": "lcl50101", 371 | "emailAddress": "user@company.com", 372 | "avatarUrls": { 373 | "16x16": "https://secure.gravatar.com/avatar/xxx?d=mm&s=16", 374 | "24x24": "https://secure.gravatar.com/avatar/xxx?d=mm&s=24", 375 | "32x32": "https://secure.gravatar.com/avatar/xxx?d=mm&s=32", 376 | "48x48": "https://secure.gravatar.com/avatar/xxx?d=mm&s=48" 377 | }, 378 | "displayName": "user", 379 | "active": true 380 | }, 381 | "created": "2012-08-09T14:45:52.000+0200", 382 | "updated": "2012-08-09T14:45:52.000+0200" 383 | } 384 | ] 385 | }, 386 | "timeoriginalestimate": null, 387 | "aggregatetimespent": null 388 | } 389 | } 390 | --------------------------------------------------------------------------------