ditz 0.1.2 → 0.2

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.
data/Changelog CHANGED
@@ -1,3 +1,10 @@
1
+ == 0.2 / 2008-04-11
2
+ * bugfix: store each issue in a separate file to avoid false conflicts
3
+ * added per-command help
4
+ * added 'log' command for recent activity
5
+ * added better exception handling---turn into pretty error messages
6
+ * added text-area commands like /edit, /reset, etc
7
+ * all times now stored in UTC
1
8
  == 0.1.2 / 2008-04-04
2
9
  * bugfix: add_reference very broken
3
10
  == 0.1.1 / 2008-04-04
data/Rakefile CHANGED
@@ -42,7 +42,7 @@ end
42
42
 
43
43
  task :upload_report do |t|
44
44
  sh "ruby -Ilib bin/ditz html ditz"
45
- sh "scp -Cr ditz wmorgan@rubyforge.org:/var/www/gforge-projects/ditz/"
45
+ sh "rsync -essh -cavz ditz wmorgan@rubyforge.org:/var/www/gforge-projects/ditz/"
46
46
  end
47
47
 
48
48
  # vim: syntax=ruby
@@ -0,0 +1,8 @@
1
+ 0.2
2
+ ---
3
+
4
+ In ditz 0.2, we store issues per file. This avoids many unnecessary conflicts
5
+ that occur in the single-file case.
6
+
7
+ To upgrade your bugs.yaml to a bugs/ directory, you must run
8
+ ditz-convert-from-monolith.
data/bin/ditz CHANGED
@@ -1,35 +1,51 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require 'rubygems'
4
+ require 'fileutils'
4
5
  require 'trollop'; include Trollop
5
6
  require "ditz"
6
7
 
8
+ PROJECT_FN = "project.yaml"
9
+ CONFIG_FN = ".ditz-config"
10
+ def ISSUE_TO_FN i; "issue-#{i.id}.yaml" end
11
+
7
12
  $opts = options do
8
- version "ditz #{Ditz::VERSION} (c) 2008 William Morgan"
9
- opt :issue_file, "Issue database file", :default => "bugs.yaml"
10
- opt :config_file, "Configuration file", :default => File.join(ENV["HOME"], ".ditz-config")
13
+ version "ditz #{Ditz::VERSION}"
14
+ opt :issue_dir, "Issue database dir", :default => "bugs"
15
+ opt :config_file, "Configuration file", :default => File.join(ENV["HOME"], CONFIG_FN)
11
16
  opt :verbose, "Verbose output", :default => false
12
17
  end
13
18
 
14
19
  cmd = ARGV.shift or die "expecting a ditz command"
15
20
  op = Ditz::Operator.new
21
+ dir = $opts[:issue_dir]
16
22
 
17
- case cmd # special cases: init and help
23
+ case cmd # some special cases not handled by Ditz::Operator
18
24
  when "init"
19
- fn = $opts[:issue_file]
20
- die "#{fn} already exists" if File.exists? fn
25
+ die "#{dir} directory already exists" if File.exists? dir
26
+ FileUtils.mkdir dir
27
+ fn = File.join dir, PROJECT_FN
21
28
  project = op.init
22
29
  project.save! fn
23
- puts "Ok, #{fn} created successfully."
30
+ puts "Ok, #{dir} directory created successfully."
24
31
  exit
25
32
  when "help"
26
- op.help
33
+ op.do "help", nil, nil, ARGV
27
34
  exit
28
35
  end
29
36
 
30
- Ditz::debug "loading issues from #{$opts[:issue_file]}"
37
+ die "No #{dir} directory---use 'ditz init' to initialize" unless File.exists? dir
38
+
31
39
  project = begin
32
- Ditz::Project.from $opts[:issue_file]
40
+ fn = File.join dir, PROJECT_FN
41
+ Ditz::debug "loading project from #{fn}"
42
+ project = Ditz::Project.from fn
43
+
44
+ fn = File.join dir, "issue-*.yaml"
45
+ Ditz::debug "loading issues from #{fn}"
46
+ project.issues = Dir[fn].map { |fn| Ditz::Issue.from fn }
47
+ Ditz::debug "found #{project.issues.size} issues"
48
+ project
33
49
  rescue SystemCallError, Ditz::Project::Error => e
34
50
  die "#{e.message} (use 'init' to initialize)"
35
51
  end
@@ -39,12 +55,11 @@ project.assign_issue_names!
39
55
  project.each_modelobject { |o| o.after_deserialize project }
40
56
 
41
57
  config = begin
42
- fn = ".ditz-config"
43
- if File.exists? fn
44
- Ditz::debug "loading config from #{fn}"
45
- Ditz::Config.from fn
58
+ if File.exists? CONFIG_FN
59
+ Ditz::debug "loading local config from #{CONFIG_FN}"
60
+ Ditz::Config.from CONFIG_FN
46
61
  else
47
- Ditz::debug "loading config from #{$opts[:config_file]}"
62
+ Ditz::debug "loading global config from #{$opts[:config_file]}"
48
63
  Ditz::Config.from $opts[:config_file]
49
64
  end
50
65
  rescue SystemCallError, Ditz::ModelObject::ModelError => e
@@ -64,14 +79,50 @@ args = []
64
79
  args << ARGV.shift until ARGV.empty?
65
80
 
66
81
  Ditz::debug "executing command #{cmd}"
67
- op.do cmd, project, config, *args
82
+ begin
83
+ op.do cmd, project, config, args
84
+ rescue Ditz::Operator::Error => e
85
+ die e.message
86
+ rescue Interrupt
87
+ exit 1
88
+ end
68
89
 
90
+ ## save project.yaml
69
91
  dirty = project.each_modelobject { |o| break true if o.changed? } || false
70
92
  if dirty
71
- Ditz::debug "project is dirty, saving"
93
+ fn = File.join dir, PROJECT_FN
94
+ Ditz::debug "project is dirty, saving #{fn}"
72
95
  project.each_modelobject { |o| o.before_serialize project }
73
- project.save! $opts[:issue_file]
96
+ project.save! fn
97
+ end
98
+
99
+ ## project issues are not model fields proper, so they must be
100
+ ## saved independently.
101
+ project.issues.each do |i|
102
+ if i.changed?
103
+ i.before_serialize project
104
+ fn = File.join dir, ISSUE_TO_FN(i)
105
+ Ditz::debug "issue #{i.name} is dirty, saving #{fn}"
106
+ i.save! fn
107
+ end
74
108
  end
109
+
110
+ project.deleted_issues.each do |i|
111
+ fn = File.join dir, ISSUE_TO_FN(i)
112
+ Ditz::debug "issue #{i.name} has been deleted, deleting #{fn}"
113
+ FileUtils.rm fn
114
+ end
115
+
116
+ unless project.added_issues.empty?
117
+ puts "You may have to inform your SCM that the following files have been added:"
118
+ project.added_issues.each { |i| puts " " + File.join(dir, ISSUE_TO_FN(i)) }
119
+ end
120
+
121
+ unless project.deleted_issues.empty?
122
+ puts "You may have to inform your SCM that the following files have been deleted:"
123
+ project.deleted_issues.each { |i| puts " " + File.join(dir, ISSUE_TO_FN(i)) }
124
+ end
125
+
75
126
  config.save! $opts[:config_file] if config.changed?
76
127
 
77
128
  # vim: syntax=ruby
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'fileutils'
5
+ require "ditz"
6
+
7
+ PROJECT_FN = "project.yaml"
8
+ CONFIG_FN = ".ditz-config"
9
+ def ISSUE_TO_FN i; "issue-#{i.id}.yaml" end
10
+
11
+ dir = "bugs"
12
+ project = Ditz::Project.from "bugs.yaml"
13
+ puts "making #{dir}"
14
+ FileUtils.mkdir dir
15
+ project.changed!
16
+ project.issues.each { |i| i.changed! }
17
+
18
+ project.validate!
19
+ project.assign_issue_names!
20
+ project.each_modelobject { |o| o.after_deserialize project }
21
+
22
+ ## save project.yaml
23
+ dirty = project.each_modelobject { |o| break true if o.changed? } || false
24
+ if dirty
25
+ fn = File.join dir, PROJECT_FN
26
+ puts "writing #{fn}"
27
+ project.each_modelobject { |o| o.before_serialize project }
28
+ project.save! fn
29
+ end
30
+
31
+ ## project issues are not model fields proper, so they must be
32
+ ## saved independently.
33
+ project.issues.each do |i|
34
+ if i.changed?
35
+ i.before_serialize project
36
+ fn = File.join dir, ISSUE_TO_FN(i)
37
+ puts "writing #{fn}"
38
+ i.save! fn
39
+ end
40
+ end
41
+
42
+ puts "You can delete bugs.yaml now."
@@ -13,6 +13,6 @@
13
13
 
14
14
  <%= render "issue_table", :show_component => false, :show_release => true %>
15
15
 
16
- <p class="footer">Generated <%= Time.now %> by <a href="http://ditz.rubyforge.org/">ditz</a>.
16
+ <p class="footer">Generated by <a href="http://ditz.rubyforge.org/">ditz</a>.
17
17
  </body>
18
18
  </html>
@@ -1,6 +1,6 @@
1
1
  module Ditz
2
2
 
3
- VERSION = "0.1.2"
3
+ VERSION = "0.2"
4
4
 
5
5
  def debug s
6
6
  puts "# #{s}" if $opts[:verbose]
@@ -77,7 +77,7 @@
77
77
  </ul>
78
78
  <% end %>
79
79
 
80
- <p class="footer">Generated <%= Time.now %> by <a href="http://ditz.rubyforge.org/">ditz</a>.
80
+ <p class="footer">Generated by <a href="http://ditz.rubyforge.org/">ditz</a>.
81
81
 
82
82
  </body>
83
83
  </html>
@@ -101,6 +101,6 @@
101
101
  <% end %>
102
102
  </table>
103
103
 
104
- <p class="footer">Generated <%= Time.now %> by <a href="http://ditz.rubyforge.org/">ditz</a>.
104
+ <p class="footer">Generated by <a href="http://ditz.rubyforge.org/">ditz</a>.
105
105
  </body>
106
106
  </html>
@@ -1,15 +1,16 @@
1
- require 'util'
1
+ require 'tempfile'
2
+ require "util"
2
3
 
3
4
  class Numeric
4
5
  def to_pretty_s
5
- if self < 20
6
- %w(no one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen)[self]
7
- else
8
- to_s
9
- end
6
+ %w(no one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen)[self] || to_s
10
7
  end
11
8
  end
12
9
 
10
+ class NilClass
11
+ def multiline prefix=nil; "" end
12
+ end
13
+
13
14
  class String
14
15
  def ucfirst; self[0..0].upcase + self[1..-1] end
15
16
  def dcfirst; self[0..0].downcase + self[1..-1] end
@@ -17,6 +18,7 @@ class String
17
18
  def underline; self + "\n" + ("-" * self.length) end
18
19
  def multiline prefix=""; blank? ? "" : "\n" + self.gsub(/^/, prefix) end
19
20
  def pluralize n; n.to_pretty_s + " " + (n == 1 ? self : self + "s") end # oh yeah
21
+ def multistrip; strip.gsub(/\n\n+/, "\n\n") end
20
22
  end
21
23
 
22
24
  class Array
@@ -52,6 +54,20 @@ class Time
52
54
  end
53
55
 
54
56
  module Lowline
57
+ def run_editor
58
+ f = Tempfile.new "ditz"
59
+ yield f
60
+ f.close
61
+
62
+ editor = ENV["EDITOR"] || "/usr/bin/vi"
63
+ cmd = "#{editor} #{f.path.inspect}"
64
+
65
+ mtime = File.mtime f.path
66
+ system cmd or raise Error, "cannot execute command: #{cmd.inspect}"
67
+
68
+ File.mtime(f.path) == mtime ? nil : f.path
69
+ end
70
+
55
71
  def ask q, opts={}
56
72
  default_s = case opts[:default]
57
73
  when nil; nil
@@ -77,16 +93,35 @@ module Lowline
77
93
  end
78
94
  end
79
95
 
96
+ def ask_via_editor q, default=nil
97
+ fn = run_editor do |f|
98
+ f.puts q.gsub(/^/, "## ")
99
+ f.puts "##"
100
+ f.puts "## Enter your text below. Lines starting with a '#' will be ignored."
101
+ f.puts
102
+ f.puts default if default
103
+ end
104
+ return unless fn
105
+ IO.read(fn).gsub(/^#.*$/, "").multistrip
106
+ end
107
+
80
108
  def ask_multiline q
81
- puts "#{q} (ctrl-d or . by itself to stop):"
109
+ puts "#{q} (ctrl-d, ., or /stop to stop, /edit to edit, /reset to reset):"
82
110
  ans = ""
83
111
  while true
84
112
  print "> "
85
- line = gets
86
- break if line =~ /^\.$/ || line.nil?
87
- ans << line.strip + "\n"
113
+ case(line = gets) && line.strip!
114
+ when /^\.$/, nil, "/stop"
115
+ break
116
+ when "/reset"
117
+ return ask_multiline(q)
118
+ when "/edit"
119
+ return ask_via_editor(q, ans)
120
+ else
121
+ ans << line + "\n"
122
+ end
88
123
  end
89
- ans.sub(/\n+$/, "")
124
+ ans.multistrip
90
125
  end
91
126
 
92
127
  def ask_yon q
@@ -129,6 +164,8 @@ module Lowline
129
164
  puts "Choose a #{name}:"
130
165
  stuff.each_with_index do |c, i|
131
166
  pretty = case to_string
167
+ when block_given? && to_string # heh
168
+ yield c
132
169
  when Symbol
133
170
  c.send to_string
134
171
  when Proc
@@ -38,10 +38,16 @@ class Project < ModelObject
38
38
 
39
39
  field :name, :default_generator => lambda { File.basename(Dir.pwd) }
40
40
  field :version, :default => Ditz::VERSION, :ask => false
41
- field :issues, :multi => true, :ask => false
42
41
  field :components, :multi => true, :generator => :get_components
43
42
  field :releases, :multi => true, :ask => false
44
43
 
44
+ ## issues are not model fields proper, so we build up their interface here.
45
+ attr_accessor :issues
46
+ def add_issue issue; added_issues << issue; issues << issue end
47
+ def drop_issue issue; deleted_issues << issue if issues.delete issue end
48
+ def added_issues; @added_issues ||= [] end
49
+ def deleted_issues; @deleted_issues ||= [] end
50
+
45
51
  def get_components
46
52
  puts <<EOS
47
53
  Issues can be tracked across the project as a whole, or the project can be
@@ -54,18 +60,15 @@ EOS
54
60
  end
55
61
 
56
62
  def issue_for issue_name
57
- issues.find { |i| i.name == issue_name } or
58
- raise Error, "has no issue with name #{issue_name.inspect}"
63
+ issues.find { |i| i.name == issue_name }
59
64
  end
60
65
 
61
66
  def component_for component_name
62
- components.find { |i| i.name == component_name } or
63
- raise Error, "has no component with name #{component_name.inspect}"
67
+ components.find { |i| i.name == component_name }
64
68
  end
65
69
 
66
70
  def release_for release_name
67
- releases.find { |i| i.name == release_name } or
68
- raise Error, "has no release with name #{release_name.inspect}"
71
+ releases.find { |i| i.name == release_name }
69
72
  end
70
73
 
71
74
  def issues_for_release release
@@ -83,7 +86,7 @@ EOS
83
86
  def assign_issue_names!
84
87
  prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
85
88
  ids = components.map { |c| [c.name, 0] }.to_h
86
- issues.each do |i|
89
+ issues.sort_by { |i| i.creation_time }.each do |i|
87
90
  i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
88
91
  end
89
92
  end
@@ -3,6 +3,13 @@ require "lowline"; include Lowline
3
3
  require "util"
4
4
  require 'sha1'
5
5
 
6
+ class Time
7
+ alias :old_to_yaml :to_yaml
8
+ def to_yaml(opts = {})
9
+ self.utc.old_to_yaml(opts)
10
+ end
11
+ end
12
+
6
13
  module Ditz
7
14
 
8
15
  class ModelObject
@@ -82,8 +89,7 @@ class ModelObject
82
89
  end
83
90
 
84
91
  def save! fn
85
- Ditz::debug "saving configuration to #{fn}"
86
- FileUtils.mv fn, "#{fn}~", :force => true rescue nil
92
+ #FileUtils.mv fn, "#{fn}~", :force => true rescue nil
87
93
  File.open(fn, "w") { |f| f.puts to_yaml }
88
94
  end
89
95
 
@@ -92,12 +98,7 @@ class ModelObject
92
98
  self
93
99
  end
94
100
 
95
- def initialize
96
- @changed = false
97
- @log_events = []
98
- end
99
-
100
- def changed?; @changed end
101
+ def changed?; @changed ||= false end
101
102
  def changed!; @changed = true end
102
103
 
103
104
  def self.create_interactively opts={}
@@ -1,6 +1,4 @@
1
- require 'tempfile'
2
1
  require 'fileutils'
3
-
4
2
  require "html"
5
3
 
6
4
  module Ditz
@@ -12,18 +10,82 @@ class Operator
12
10
  def method_to_op meth; meth.to_s.gsub("_", "-") end
13
11
  def op_to_method op; op.gsub("-", "_").intern end
14
12
 
15
- def operation method, desc
13
+ def operation method, desc, *args_spec
16
14
  @operations ||= {}
17
- @operations[method] = desc
15
+ @operations[method] = { :desc => desc, :args_spec => args_spec }
18
16
  end
19
17
 
20
18
  def operations
21
19
  @operations.map { |k, v| [method_to_op(k), v] }.sort_by { |k, v| k }
22
20
  end
23
21
  def has_operation? op; @operations.member? op_to_method(op) end
22
+
23
+ def parse_releases_arg project, releases_arg
24
+ ret = []
25
+
26
+ releases, show_unassigned, force_show = case releases_arg
27
+ when nil; [project.releases, true, false]
28
+ when "unassigned"; [[], true, true]
29
+ else
30
+ release = project.release_for(releases_arg)
31
+ raise Error, "no release with name #{releases_arg}" unless release
32
+ [[release], false, true]
33
+ end
34
+
35
+ releases.each do |r|
36
+ next if r.released? unless force_show
37
+
38
+ bugs = project.issues.
39
+ select { |i| i.type == :bugfix && i.release == r.name }
40
+ feats = project.issues.
41
+ select { |i| i.type == :feature && i.release == r.name }
42
+
43
+ #next if bugs.empty? && feats.empty? unless force_show
44
+
45
+ ret << [r, bugs, feats]
46
+ end
47
+
48
+ return ret unless show_unassigned
49
+
50
+ bugs = project.issues.select { |i| i.type == :bugfix && i.release.nil? }
51
+ feats = project.issues.select { |i| i.type == :feature && i.release.nil? }
52
+
53
+ return ret if bugs.empty? && feats.empty? unless force_show
54
+ ret << [nil, bugs, feats]
55
+ end
56
+ private :parse_releases_arg
57
+
58
+ def build_args project, method, args
59
+ command = "command '#{method_to_op method}'"
60
+ built_args = @operations[method][:args_spec].map do |spec|
61
+ val = args.shift
62
+ case spec
63
+ when :issue
64
+ raise Error, "#{command} requires an issue name" unless val
65
+ project.issue_for(val) or raise Error, "no issue with name #{val}"
66
+ when :release
67
+ raise Error, "#{command} requires a release name" unless val
68
+ project.release_for(val) or raise Error, "no release with name #{val}"
69
+ when :maybe_release
70
+ parse_releases_arg project, val
71
+ when :string
72
+ raise Error, "#{command} requires a string" unless val
73
+ val
74
+ else
75
+ val # no translation for other types
76
+ end
77
+ end
78
+ raise Error, "too many arguments for #{command}" unless args.empty?
79
+ built_args
80
+ end
81
+ end
82
+
83
+ def do op, project, config, args
84
+ meth = self.class.op_to_method(op)
85
+ built_args = self.class.build_args project, meth, args
86
+ send meth, project, config, *built_args
24
87
  end
25
88
 
26
- def do op, *a; send self.class.op_to_method(op), *a end
27
89
  %w(operations has_operation?).each do |m|
28
90
  define_method(m) { |*a| self.class.send m, *a }
29
91
  end
@@ -33,15 +95,40 @@ class Operator
33
95
  Project.create_interactively
34
96
  end
35
97
 
36
- operation :help, "List all registered commands"
37
- def help
98
+ operation :help, "List all registered commands", :maybe_command
99
+ def help project, config, command
100
+ return help_single(command) if command
38
101
  puts <<EOS
39
- Registered commands:
102
+ Ditz commands:
103
+
40
104
  EOS
41
105
  ops = self.class.operations
42
- len = ops.map { |name, desc| name.to_s.length }.max
43
- ops.each { |name, desc| printf "%#{len}s: %s\n", name, desc }
44
- puts
106
+ len = ops.map { |name, op| name.to_s.length }.max
107
+ ops.each do |name, opts|
108
+ printf " %#{len}s: %s\n", name, opts[:desc]
109
+ end
110
+ puts <<EOS
111
+
112
+ Use 'ditz help <command>' for details.
113
+ EOS
114
+ end
115
+
116
+ def help_single command
117
+ name, opts = self.class.operations.find { |name, spec| name == command }
118
+ raise Error, "no such ditz command '#{command}'" unless name
119
+ args = opts[:args_spec].map do |spec|
120
+ case spec.to_s
121
+ when /^maybe_(.*)$/
122
+ "[#{$1}]"
123
+ else
124
+ "<#{spec.to_s}>"
125
+ end
126
+ end.join(" ")
127
+
128
+ puts <<EOS
129
+ #{opts[:desc]}.
130
+ Usage: ditz #{name} #{args}
131
+ EOS
45
132
  end
46
133
 
47
134
  operation :add, "Add a bug/feature request"
@@ -54,9 +141,8 @@ EOS
54
141
  puts "Added issue #{issue.name}."
55
142
  end
56
143
 
57
- operation :drop, "Drop a bug/feature request"
58
- def drop project, config, issue_name
59
- issue = project.issue_for issue_name
144
+ operation :drop, "Drop a bug/feature request", :issue
145
+ def drop project, config, issue
60
146
  project.drop_issue issue
61
147
  puts "Dropped #{issue.name}. Note that other issue names may have changed."
62
148
  end
@@ -77,56 +163,19 @@ EOS
77
163
  puts "Added component #{component.name}."
78
164
  end
79
165
 
80
- operation :add_reference, "Add a reference to an issue"
81
- def add_reference project, config, issue_name
82
- issue = project.issue_for issue_name
166
+ operation :add_reference, "Add a reference to an issue", :issue
167
+ def add_reference project, config, issue
168
+ puts "Adding a reference to #{issue.name}: #{issue.title}."
83
169
  reference = ask "Reference"
84
170
  comment = ask_multiline "Comments"
85
171
  issue.add_reference reference
86
172
  issue.log "added reference #{issue.references.size}", config.user, comment
87
- puts "Added reference to #{issue.name}"
88
- end
89
-
90
- def parse_releases_arg project, releases_arg
91
- ret = []
92
-
93
- releases, show_unassigned, force_show = case releases_arg
94
- when nil; [project.releases, true, false]
95
- when "unassigned"; [[], true, true]
96
- else
97
- [[project.release_for(releases_arg)], false, true]
98
- end
99
-
100
- releases.each do |r|
101
- next if r.released? unless force_show
102
-
103
- bugs = project.issues.
104
- select { |i| i.type == :bugfix && i.release == r.name }
105
- feats = project.issues.
106
- select { |i| i.type == :feature && i.release == r.name }
107
-
108
- #next if bugs.empty? && feats.empty? unless force_show
109
-
110
- ret << [r, bugs, feats]
111
- end
112
-
113
- return ret unless show_unassigned
114
-
115
- bugs = project.issues.select { |i| i.type == :bugfix && i.release.nil? }
116
- feats = project.issues.select { |i| i.type == :feature && i.release.nil? }
117
-
118
- return ret if bugs.empty? && feats.empty? unless force_show
119
- ret << [nil, bugs, feats]
173
+ puts "Added reference to #{issue.name}."
120
174
  end
121
175
 
122
- operation :status, "Show project status"
123
- def status project, config, release=nil
124
- if project.releases.empty?
125
- puts "No releases."
126
- return
127
- end
128
-
129
- parse_releases_arg(project, release).each do |r, bugs, feats|
176
+ operation :status, "Show project status", :maybe_release
177
+ def status project, config, releases
178
+ releases.each do |r, bugs, feats|
130
179
  title, bar = [r ? r.name : "unassigned", status_bar_for(bugs + feats)]
131
180
 
132
181
  ncbugs = bugs.count_of { |b| b.closed? }
@@ -134,7 +183,9 @@ EOS
134
183
  pcbugs = 100.0 * (bugs.empty? ? 1.0 : ncbugs.to_f / bugs.size)
135
184
  pcfeats = 100.0 * (feats.empty? ? 1.0 : ncfeats.to_f / feats.size)
136
185
 
137
- special = if bugs.empty? && feats.empty?
186
+ special = if r && r.released?
187
+ "(released)"
188
+ elsif bugs.empty? && feats.empty?
138
189
  "(no issues)"
139
190
  elsif ncbugs == bugs.size && ncfeats == feats.size
140
191
  "(ready for release)"
@@ -145,6 +196,11 @@ EOS
145
196
  printf "%-10s %2d/%2d (%3.0f%%) bugs, %2d/%2d (%3.0f%%) features %s\n",
146
197
  title, ncbugs, bugs.size, pcbugs, ncfeats, feats.size, pcfeats, special
147
198
  end
199
+
200
+ if project.releases.empty?
201
+ puts "No releases."
202
+ return
203
+ end
148
204
  end
149
205
 
150
206
  def status_bar_for issues
@@ -155,24 +211,25 @@ EOS
155
211
  end
156
212
 
157
213
  def todo_list_for issues
214
+ return if issues.empty?
158
215
  name_len = issues.max_of { |i| i.name.length }
159
216
  issues.map do |i|
160
217
  sprintf "%s %#{name_len}s: %s\n", i.status_widget, i.name, i.title
161
218
  end.join
162
219
  end
163
220
 
164
- operation :todo, "Generate todo list"
165
- def todo project, config, release=nil
166
- actually_do_todo project, config, release, false
221
+ operation :todo, "Generate todo list", :maybe_release
222
+ def todo project, config, releases
223
+ actually_do_todo project, config, releases, false
167
224
  end
168
225
 
169
- operation :todo_full, "Generate full todo list, including completed items"
170
- def todo_full project, config, release=nil
171
- actually_do_todo project, config, release, true
226
+ operation :todo_full, "Generate full todo list, including completed items", :maybe_release
227
+ def todo_full project, config, releases
228
+ actually_do_todo project, config, releases, true
172
229
  end
173
230
 
174
- def actually_do_todo project, config, release, full
175
- parse_releases_arg(project, release).each do |r, bugs, feats|
231
+ def actually_do_todo project, config, releases, full
232
+ releases.each do |r, bugs, feats|
176
233
  if r
177
234
  puts "Version #{r.name} (#{r.status}):"
178
235
  else
@@ -180,14 +237,13 @@ EOS
180
237
  end
181
238
  issues = bugs + feats
182
239
  issues = issues.select { |i| i.open? } unless full
183
- print todo_list_for(issues.sort_by { |i| i.sort_order })
240
+ puts(todo_list_for(issues.sort_by { |i| i.sort_order }) || "No open issues.")
184
241
  puts
185
242
  end
186
243
  end
187
244
 
188
- operation :show, "Describe a single issue"
189
- def show project, config, name
190
- issue = project.issue_for name
245
+ operation :show, "Describe a single issue", :issue
246
+ def show project, config, issue
191
247
  status = case issue.status
192
248
  when :closed
193
249
  "#{issue.status_string}: #{issue.disposition_string}"
@@ -198,11 +254,13 @@ EOS
198
254
  #{"Issue #{issue.name}".underline}
199
255
  Title: #{issue.title}
200
256
  Description: #{issue.interpolated_desc(project.issues).multiline " "}
257
+ Type: #{issue.type}
201
258
  Status: #{status}
202
259
  Creator: #{issue.reporter}
203
260
  Age: #{issue.creation_time.ago}
204
261
  Release: #{issue.release}
205
262
  References: #{issue.references.listify " "}
263
+ Identifier: #{issue.id}
206
264
 
207
265
  Event log:
208
266
  #{format_log_events issue.log_events}
@@ -216,25 +274,24 @@ EOS
216
274
  end.join("\n")
217
275
  end
218
276
 
219
- operation :start, "Start work on an issue"
220
- def start project, config, name
221
- issue = project.issue_for name
277
+ operation :start, "Start work on an issue", :issue
278
+ def start project, config, issue
279
+ puts "Starting work on issue #{issue.name}: #{issue.title}."
222
280
  comment = ask_multiline "Comments"
223
281
  issue.start_work config.user, comment
224
282
  puts "Recorded start of work for #{issue.name}."
225
283
  end
226
284
 
227
- operation :stop, "Stop work on an issue"
228
- def stop project, config, name
229
- issue = project.issue_for name
285
+ operation :stop, "Stop work on an issue", :issue
286
+ def stop project, config, issue
287
+ puts "Stopping work on issue #{issue.name}: #{issue.title}."
230
288
  comment = ask_multiline "Comments"
231
289
  issue.stop_work config.user, comment
232
290
  puts "Recorded work stop for #{issue.name}."
233
291
  end
234
292
 
235
- operation :close, "Close an issue"
236
- def close project, config, name
237
- issue = project.issue_for name
293
+ operation :close, "Close an issue", :issue
294
+ def close project, config, issue
238
295
  puts "Closing issue #{issue.name}: #{issue.title}."
239
296
  disp = ask_for_selection Issue::DISPOSITIONS, "disposition", lambda { |x| Issue::DISPOSITION_STRINGS[x] || x.to_s }
240
297
  comment = ask_multiline "Comments"
@@ -242,31 +299,39 @@ EOS
242
299
  puts "Closed issue #{issue.name} with disposition #{issue.disposition_string}."
243
300
  end
244
301
 
245
- operation :assign, "Assign an issue to a release"
246
- def assign project, config, issue_name
247
- issue = project.issue_for issue_name
302
+ operation :assign, "Assign an issue to a release", :issue
303
+ def assign project, config, issue
248
304
  puts "Issue #{issue.name} currently " + if issue.release
249
305
  "assigned to release #{issue.release}."
250
306
  else
251
307
  "not assigned to any release."
252
308
  end
253
- release = ask_for_selection project.releases, "release", :name
309
+
310
+ releases = project.releases.sort_by { |r| (r.release_time || 0).to_i }
311
+ releases -= [releases.find { |r| r.name == issue.release }] if issue.release
312
+ release = ask_for_selection(releases, "release") do |r|
313
+ r.name + if r.released?
314
+ " (released #{r.release_time.pretty_date})"
315
+ else
316
+ " (unreleased)"
317
+ end
318
+ end
254
319
  comment = ask_multiline "Comments"
255
320
  issue.assign_to_release release, config.user, comment
256
- puts "Assigned #{issue.name} to #{release.name}"
321
+ puts "Assigned #{issue.name} to #{release.name}."
257
322
  end
258
323
 
259
- operation :unassign, "Unassign an issue from any releases"
260
- def unassign project, config, issue_name
261
- issue = project.issue_for issue_name
324
+ operation :unassign, "Unassign an issue from any releases", :issue
325
+ def unassign project, config, issue
326
+ puts "Unassigning issue #{issue.name}: #{issue.title}."
262
327
  comment = ask_multiline "Comments"
263
328
  issue.unassign config.user, comment
264
329
  puts "Unassigned #{issue.name}."
265
330
  end
266
331
 
267
- operation :comment, "Comment on an issue"
268
- def comment project, config, issue_name
269
- issue = project.issue_for issue_name
332
+ operation :comment, "Comment on an issue", :issue
333
+ def comment project, config, issue
334
+ puts "Commenting on issue #{issue.name}: #{issue.title}."
270
335
  comment = ask_multiline "Comments"
271
336
  issue.log "commented", config.user, comment
272
337
  puts "Comments recorded for #{issue.name}."
@@ -274,32 +339,31 @@ EOS
274
339
 
275
340
  operation :releases, "Show releases"
276
341
  def releases project, config
277
- project.releases.each do |r|
342
+ a, b = project.releases.partition { |r| r.released? }
343
+ (b + a.sort_by { |r| r.release_time }).each do |r|
278
344
  status = r.released? ? "released #{r.release_time.pretty_date}" : r.status
279
345
  puts "#{r.name} (#{status})"
280
346
  end
281
347
  end
282
348
 
283
- operation :release, "Release a release"
284
- def release project, config, release_name
285
- release = project.release_for release_name
349
+ operation :release, "Release a release", :release
350
+ def release project, config, release
286
351
  comment = ask_multiline "Comments"
287
352
  release.release! project, config.user, comment
288
353
  puts "Release #{release.name} released!"
289
354
  end
290
355
 
291
- operation :changelog, "Generate a changelog for a release"
292
- def changelog project, config, relnames
293
- parse_releases_arg(project, relnames).each do |r, bugs, feats|
294
- puts "== #{r.name} / #{r.release_time.pretty_date}" if r.released?
295
- feats.select { |f| f.closed? }.each { |i| puts "* #{i.title}" }
296
- bugs.select { |f| f.closed? }.each { |i| puts "* bugfix: #{i.title}" }
297
- end
356
+ operation :changelog, "Generate a changelog for a release", :release
357
+ def changelog project, config, r
358
+ feats, bugs = project.issues_for_release(r).partition { |i| i.feature? }
359
+ puts "== #{r.name} / #{r.released? ? r.release_time.pretty_date : 'unreleased'}"
360
+ feats.select { |f| f.closed? }.each { |i| puts "* #{i.title}" }
361
+ bugs.select { |f| f.closed? }.each { |i| puts "* bugfix: #{i.title}" }
298
362
  end
299
363
 
300
- operation :html, "Generate html status pages"
301
- def html project, config, dir="html"
302
- #FileUtils.rm_rf dir
364
+ operation :html, "Generate html status pages", :maybe_dir
365
+ def html project, config, dir
366
+ dir ||= "html"
303
367
  Dir.mkdir dir unless File.exists? dir
304
368
 
305
369
  ## find the ERB templates. this is my brilliant approach
@@ -360,6 +424,7 @@ EOS
360
424
  :past_releases => past_rels, :upcoming_releases => upcoming_rels,
361
425
  :components => project.components)
362
426
  end
427
+ puts "Local generated URL: file://#{File.expand_path(fn)}"
363
428
  end
364
429
 
365
430
  operation :validate, "Validate project status"
@@ -367,36 +432,46 @@ EOS
367
432
  ## a no-op
368
433
  end
369
434
 
370
- operation :grep, "Show issues matching a string or regular expression"
435
+ operation :grep, "Show issues matching a string or regular expression", :string
371
436
  def grep project, config, match
372
437
  re = /#{match}/
373
438
  issues = project.issues.select { |i| i.title =~ re || i.desc =~ re }
374
- print_todo issues
439
+ puts(todo_list_for(issues) || "No matching issues.")
375
440
  end
376
441
 
377
- operation :edit, "Edit an issue"
378
- def edit project, config, issue_name
379
- issue = project.issue_for issue_name
442
+ operation :log, "Show recent activity"
443
+ def log project, config
444
+ project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
445
+ flatten_one_level.sort_by { |e| e.first.first }.reverse.
446
+ each do |(date, author, what, comment), i|
447
+ puts <<EOS
448
+ date : #{date.localtime} (#{date.ago} ago)
449
+ author: #{author}
450
+
451
+ #{i.name}: #{i.title}
452
+ #{what}
453
+ #{comment.multiline " "}
454
+ EOS
455
+ puts unless comment.blank?
456
+ end
457
+ end
458
+
459
+ operation :edit, "Edit an issue", :issue
460
+ def edit project, config, issue
380
461
  data = { :title => issue.title, :description => issue.desc,
381
462
  :reporter => issue.reporter }
382
463
 
383
- f = Tempfile.new("ditz")
384
- f.puts data.to_yaml
385
- f.close
386
- editor = ENV["EDITOR"] || "/usr/bin/vi"
387
- cmd = "#{editor} #{f.path.inspect}"
388
- Ditz::debug "running: #{cmd}"
464
+ fn = run_editor { |f| f.puts data.to_yaml }
389
465
 
390
- mtime = File.mtime f.path
391
- system cmd or raise Error, "cannot execute command: #{cmd.inspect}"
392
- if File.mtime(f.path) == mtime
466
+ unless fn
393
467
  puts "Aborted."
394
468
  return
395
469
  end
396
470
 
397
471
  comment = ask_multiline "Comments"
472
+
398
473
  begin
399
- edits = YAML.load_file f.path
474
+ edits = YAML.load_file fn
400
475
  if issue.change edits, config.user, comment
401
476
  puts "Changed recorded."
402
477
  else
@@ -61,7 +61,7 @@
61
61
  <% end %>
62
62
  </table>
63
63
 
64
- <p class="footer">Generated <%= Time.now %> by <a href="http://ditz.rubyforge.org/">ditz</a>.
64
+ <p class="footer">Generated by <a href="http://ditz.rubyforge.org/">ditz</a>.
65
65
 
66
66
  </body>
67
67
  </html>
@@ -22,6 +22,6 @@
22
22
  <% end %>
23
23
  </table>
24
24
 
25
- <p class="footer">Generated <%= Time.now %> by <a href="http://ditz.rubyforge.org/">ditz</a>.
25
+ <p class="footer">Generated by <a href="http://ditz.rubyforge.org/">ditz</a>.
26
26
  </body>
27
27
  </html>
@@ -30,4 +30,15 @@ class Array
30
30
  def to_h
31
31
  Hash[*flatten]
32
32
  end
33
+
34
+ def flatten_one_level
35
+ inject([]) do |ret, e|
36
+ case e
37
+ when Array
38
+ ret + e
39
+ else
40
+ ret << e
41
+ end
42
+ end
43
+ end
33
44
  end
metadata CHANGED
@@ -3,8 +3,8 @@ rubygems_version: 0.9.0
3
3
  specification_version: 1
4
4
  name: ditz
5
5
  version: !ruby/object:Gem::Version
6
- version: 0.1.2
7
- date: 2008-04-04 00:00:00 -07:00
6
+ version: "0.2"
7
+ date: 2008-04-11 07:00:00 Z
8
8
  summary: A simple issue tracker designed to integrate well with distributed version control systems like git and darcs. State is saved to a YAML file kept under version control, allowing issues to be closed/added/modified as part of a commit.
9
9
  require_paths:
10
10
  - lib
@@ -32,7 +32,9 @@ files:
32
32
  - Changelog
33
33
  - README.txt
34
34
  - Rakefile
35
+ - ReleaseNotes
35
36
  - bin/ditz
37
+ - bin/ditz-convert-from-monolith
36
38
  - lib/component.rhtml
37
39
  - lib/ditz.rb
38
40
  - lib/html.rb
@@ -56,6 +58,7 @@ extra_rdoc_files:
56
58
  - README.txt
57
59
  executables:
58
60
  - ditz
61
+ - ditz-convert-from-monolith
59
62
  extensions: []
60
63
 
61
64
  requirements: []