crab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ require "crab/version"
2
+
3
+ require "crab/utilities"
4
+ require "crab/rally"
5
+ require "crab/story"
6
+ require "crab/cli"
7
+ require "crab/pull"
8
+ require "crab/login"
9
+ require "crab/list"
10
+ require "crab/find"
11
+ require "crab/update"
12
+ require "crab/show"
13
+ require "crab/scenario"
14
+ require "crab/project"
15
+ require "crab/cucumber_feature"
16
+ require "crab/cucumber_scenario"
17
+
18
+ module Crab
19
+ end
@@ -0,0 +1,112 @@
1
+ require 'trollop'
2
+
3
+ module Crab
4
+
5
+ SUB_COMMANDS = %w(pull login list update show find project)
6
+
7
+ class CLI
8
+ def self.start
9
+ global_opts = Trollop::options do
10
+ version "crab version #{Crab::VERSION}"
11
+ banner """
12
+ crab version #{Crab::VERSION}: A Cucumber-Rally bridge
13
+
14
+ login Persistently authenticate user with Rally
15
+ project Persistently select project to work with in Rally
16
+ list Lists stories
17
+ update Update a story (name, estimate, etc)
18
+ show Show a story (and its test cases) as a Cucumber feature
19
+ pull Downloads stories (and its test cases) as Cucumber feature files
20
+ find Find stories by text in name, description or notes
21
+ """
22
+ stop_on SUB_COMMANDS
23
+ end
24
+
25
+ cmd = ARGV.shift # get the subcommand
26
+ case cmd
27
+ when "pull" # parse delete options
28
+ cmd_opts = Trollop::options do
29
+ banner "crab pull: pulls stories from Rally and writes them out as Cucumber features
30
+
31
+ Usage: crab [options] pull story1 [story2 ...]"
32
+ end
33
+
34
+ Crab::Pull.new(global_opts, cmd_opts, ARGV).run
35
+
36
+ when "show"
37
+ cmd_opts = Trollop::options do
38
+ banner "crab show: displays a story in Rally as a Cucumber feature
39
+
40
+ Usage: crab [options] show story"
41
+ end
42
+
43
+ Crab::Show.new(global_opts, cmd_opts, ARGV).run
44
+
45
+ when "login"
46
+ cmd_opts = Trollop::options do
47
+ banner "crab login: logs into Rally
48
+
49
+ Usage: crab [options] login"
50
+ opt :username, "Username", :type => String, :short => "-u"
51
+ opt :password, "Password", :type => String, :short => "-p"
52
+ end
53
+
54
+ Crab::Login.new(global_opts, cmd_opts).run
55
+
56
+ when "list"
57
+ cmd_opts = Trollop::options do
58
+ banner "crab list: lists stories in Rally
59
+
60
+ Usage: crab [options] list"
61
+ opt :pagesize, "Number of items to fetch per page", :short => "-s", :default => 100
62
+ opt :project, "Project to use (required unless set by 'crab project')", :short => "-p", :type => String
63
+ end
64
+
65
+ Crab::List.new(global_opts, cmd_opts).run
66
+
67
+ when "update"
68
+ cmd_opts = Trollop::options do
69
+ banner "crab update: update a story in Rally
70
+
71
+ Usage: crab [options] update story [options]"
72
+ opt :name, "Name (title)", :type => String, :short => "-n"
73
+ opt :state, "State (one of: #{Crab::Story::VALID_STATES.join(" ")})", :type => String, :short => "-t"
74
+ opt :estimate, "Estimate", :type => :int, :short => "-e"
75
+ opt :iteration, "Iteration", :type => String, :short => "-i"
76
+ opt :release, "Release", :type => String, :short => "-r"
77
+ opt :blocked, "Blocked", :short => "-b"
78
+ opt :unblocked, "Unblocked", :short => "-u"
79
+ opt :parent, "Parent", :type => String, :short => "-p"
80
+ end
81
+
82
+ Crab::Update.new(global_opts, cmd_opts, ARGV).run
83
+
84
+ when "find"
85
+ cmd_opts = Trollop::options do
86
+ banner "crab find: find a story in Rally
87
+
88
+ Usage: crab [options] find [options] text"
89
+ opt :project, "Project to use (required unless set by 'crab project')", :short => "-p", :type => String
90
+ end
91
+
92
+ Crab::Find.new(global_opts, cmd_opts, ARGV).run
93
+
94
+ when "project"
95
+ cmd_opts = Trollop::options do
96
+ banner "crab project: persistently select project to work with in Rally
97
+
98
+ Usage: crab [options] project name"
99
+ end
100
+
101
+ Crab::Project.new(global_opts, cmd_opts, ARGV).run
102
+
103
+ else
104
+ if cmd
105
+ Trollop::die "Unknown subcommand #{cmd.inspect}"
106
+ else
107
+ Trollop::die "Unknown subcommand"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,17 @@
1
+ require 'fileutils'
2
+
3
+ module Crab
4
+
5
+ class CucumberFeature
6
+ def generate_from(story)
7
+ text = <<-FEATURE
8
+ Feature: [#{story.formatted_id}] #{story.name}
9
+
10
+ #{story.description}
11
+
12
+ #{Array(story.scenarios).map {|scenario| CucumberScenario.new.generate_from scenario }}
13
+ FEATURE
14
+ text.strip
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module Crab
2
+ class CucumberScenario
3
+ def generate_from(scenario)
4
+ return <<-SCENARIO
5
+
6
+ Scenario: [#{scenario.formatted_id}] #{scenario.name}
7
+ #{scenario.steps.join("\n ")}
8
+ SCENARIO
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ module Crab
2
+ class Find
3
+
4
+ include Utilities
5
+
6
+ def initialize(global_opts, cmd_opts, args)
7
+ @global_opts = global_opts
8
+ @cmd_opts = cmd_opts
9
+ @args = args
10
+ @rally = Rally.new
11
+ end
12
+
13
+ def run
14
+ pattern = @args.map(&:strip).reject(&:empty?)
15
+ Trollop::die "No search pattern given" if pattern.empty?
16
+
17
+ project_name = valid_project_name(@cmd_opts)
18
+
19
+ @rally.connect
20
+
21
+ project = @rally.find_project(project_name)
22
+
23
+ Trollop::die "Project #{@cmd_opts[:project].inspect} not found" if project.nil?
24
+
25
+ @rally.find_story(project, pattern).each do |story|
26
+ puts "#{story.formatted_id}: #{story.name} (#{story.state})"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ module Crab
2
+ class List
3
+
4
+ include Utilities
5
+
6
+ def initialize(global_opts, cmd_opts)
7
+ @global_opts = global_opts
8
+ @cmd_opts = cmd_opts
9
+ @rally = Rally.new
10
+ end
11
+
12
+ def run
13
+ @rally.connect
14
+
15
+ project_name = valid_project_name(@cmd_opts)
16
+
17
+ opts = {
18
+ :pagesize => @cmd_opts[:pagesize],
19
+ :project => @rally.find_project(project_name),
20
+ }
21
+
22
+ Trollop::die "Project #{@cmd_opts[:project].inspect} not found" if opts[:project].nil?
23
+
24
+ @rally.find_all_stories(opts).each do |story|
25
+ puts "#{story.formatted_id}: #{story.name} (#{story.state})"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ require 'highline/import'
2
+
3
+ module Crab
4
+ class Login
5
+
6
+ include Utilities
7
+
8
+ def initialize(global_opts, cmd_opts)
9
+ @global_opts = global_opts
10
+ @cmd_opts = cmd_opts
11
+ end
12
+
13
+ def run
14
+ username = @cmd_opts[:username_given] ? @cmd_opts[:username] : ask("Username: ")
15
+ password = @cmd_opts[:password_given] ? @cmd_opts[:password] : ask("Password: ") {|q| q.echo = false }
16
+
17
+ File.open(credentials_file, 'w') do |file|
18
+ file.puts username
19
+ file.puts password
20
+ end
21
+
22
+ puts "Logged in as #{username}"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,39 @@
1
+ module Crab
2
+ class Project
3
+
4
+ def self.current_project_name
5
+ if File.exists? ".rally_project"
6
+ File.read(".rally_project").strip
7
+ end
8
+ end
9
+
10
+ def initialize(global_opts, cmd_opts, args)
11
+ @global_opts = global_opts
12
+ @cmd_opts = cmd_opts
13
+ @args = args
14
+ @rally = Rally.new
15
+ end
16
+
17
+ def run
18
+ current_project_name = self.class.current_project_name
19
+ if current_project_name.present?
20
+ puts current_project_name
21
+
22
+ elsif @args.reject {|arg| arg.blank? }.empty?
23
+ puts "No project currently selected."
24
+
25
+ else
26
+ name = @args.join(" ").strip
27
+
28
+ @rally.connect
29
+
30
+ project = @rally.find_project name
31
+ Trollop::die "#{name.inspect} is not a valid project" if project.nil?
32
+
33
+ File.open(".rally_project", "w") do |file|
34
+ file.puts project.name
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,32 @@
1
+ require 'active_support/all'
2
+
3
+ module Crab
4
+
5
+ class Pull
6
+
7
+ def initialize(global_options, pull_options, story_numbers)
8
+ @global_options = global_options
9
+ @pull_options = pull_options
10
+ @story_numbers = story_numbers
11
+ @rally = Crab::Rally.new
12
+ end
13
+
14
+ def run
15
+ @rally.connect
16
+
17
+ @story_numbers.each do |story_number|
18
+ story = @rally.find_story_with_id story_number
19
+ Trollop::die "Could not find story with ID #{story_number}" if story.nil?
20
+
21
+ puts "#{story.formatted_id}: #{story.full_file_name}"
22
+
23
+ ::FileUtils.mkdir_p File.dirname(story.full_file_name)
24
+ ::FileUtils.touch story.full_file_name
25
+
26
+ File.open(story.full_file_name, "w") do |file|
27
+ file.write CucumberFeature.new.generate_from story
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ require 'rally_rest_api'
2
+
3
+ module Crab
4
+ class Rally
5
+
6
+ include Utilities
7
+
8
+ def connect
9
+ get_credentials
10
+ @rally = ::RallyRestAPI.new :username => @username, :password => @password
11
+ end
12
+
13
+ def get_credentials
14
+ @username, @password = File.read(valid_credentials_file).split /\n/
15
+ end
16
+
17
+ def find_story_with_id story_id
18
+ Crab::Story.new @rally.find(:hierarchical_requirement) { equal :formatted_i_d, story_id }.first
19
+ end
20
+
21
+ def find_all_stories(opts={})
22
+ @rally.find_all(:hierarchical_requirement, {:fetch => true}.merge(opts)).map {|s| Crab::Story.new s }
23
+ end
24
+
25
+ def find_story(project, pattern)
26
+ @rally.find(:hierarchical_requirement, :fetch => true, :project => project) {
27
+ pattern.each do |word|
28
+ _or_ {
29
+ contains :name, word
30
+ contains :description, word
31
+ contains :notes, word
32
+ }
33
+ end
34
+ }.map {|s| Crab::Story.new s }
35
+ end
36
+
37
+ def find_project(name)
38
+ @rally.find(:project, :fetch => true) { equal :name, name }.first
39
+ end
40
+
41
+ def find_iteration_by_name(name)
42
+ @rally.find(:iteration) { equal :name, name }.first
43
+ end
44
+
45
+ def find_release_by_name(name)
46
+ @rally.find(:release) { equal :name, name }.first
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,23 @@
1
+ module Crab
2
+
3
+ class Scenario
4
+
5
+ def initialize(rally_test_case)
6
+ @rally_test_case = rally_test_case
7
+ end
8
+
9
+ def formatted_id
10
+ @rally_test_case.formatted_i_d
11
+ end
12
+
13
+ def name
14
+ @rally_test_case.name
15
+ end
16
+
17
+ def steps
18
+ Array(@rally_test_case.steps).map {|step| step.input }
19
+ end
20
+
21
+ end
22
+
23
+ end
@@ -0,0 +1,17 @@
1
+ module Crab
2
+ class Show
3
+ def initialize(global_opts, cmd_opts, args)
4
+ @global_opts = global_opts
5
+ @cmd_opts = cmd_opts
6
+ @story_id = args.first
7
+ @rally = Rally.new
8
+ end
9
+
10
+ def run
11
+ @rally.connect
12
+
13
+ story = @rally.find_story_with_id @story_id
14
+ puts CucumberFeature.new.generate_from story
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,63 @@
1
+ require 'sanitize'
2
+
3
+ module Crab
4
+
5
+ class Story
6
+
7
+ VALID_STATES = %w{Grooming Defined In-Progress Completed Accepted Released}
8
+
9
+ def initialize(rally_story)
10
+ @rally_story = rally_story
11
+ end
12
+
13
+ def name
14
+ @rally_story.name
15
+ end
16
+
17
+ def file_name
18
+ "#{formatted_id}-#{name.parameterize.dasherize}.feature"
19
+ end
20
+
21
+ def full_file_name
22
+ "features/#{state}/#{file_name}"
23
+ end
24
+
25
+ def formatted_id
26
+ @rally_story.formatted_i_d
27
+ end
28
+
29
+ def state
30
+ (@rally_story.schedule_state || "unknown").parameterize.underscore
31
+ end
32
+
33
+ def description
34
+ # this could use a lot of rethinking :(
35
+ # biggest problem is that Cucumber breaks if text in description looks like something
36
+ # it might know how to parse, but doesn't
37
+ # our feature descriptions are quite like that, so I was being ultra-conservative
38
+ sanitize(@rally_story.description || '').gsub(/ +/, "\n").gsub(/\n\n/, "\n").gsub(/\n/, "\n ")
39
+ end
40
+
41
+ def scenarios
42
+ Array(@rally_story.test_cases).map {|tc| Scenario.new tc }
43
+ end
44
+
45
+ def update(opts)
46
+ @rally_story.update opts
47
+ end
48
+
49
+ def rally_object
50
+ @rally_story
51
+ end
52
+
53
+ private
54
+
55
+ # took a while to figure out that we need to remove the CSS from inside embedded <style> tags!
56
+ # Rally uses some crazy rich text editor that I'd be soooooo happy to disable, somehow. Chrome Extension, perhaps?
57
+ def sanitize(source)
58
+ Sanitize.clean source, :remove_contents => %w{style}
59
+ end
60
+
61
+ end
62
+
63
+ end