ursm-ditz 0.4 → 0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. data/Changelog +15 -0
  2. data/INSTALL +20 -0
  3. data/LICENSE +674 -0
  4. data/Manifest.txt +40 -0
  5. data/PLUGINS.txt +140 -0
  6. data/README.txt +43 -27
  7. data/Rakefile +36 -3
  8. data/ReleaseNotes +6 -0
  9. data/bin/ditz +49 -63
  10. data/contrib/completion/ditz.bash +25 -9
  11. data/lib/ditz.rb +52 -7
  12. data/lib/ditz/file-storage.rb +54 -0
  13. data/lib/{hook.rb → ditz/hook.rb} +1 -1
  14. data/lib/{html.rb → ditz/html.rb} +42 -4
  15. data/lib/{lowline.rb → ditz/lowline.rb} +31 -11
  16. data/lib/{model-objects.rb → ditz/model-objects.rb} +53 -21
  17. data/lib/ditz/model.rb +321 -0
  18. data/lib/{operator.rb → ditz/operator.rb} +122 -67
  19. data/lib/ditz/plugins/git-sync.rb +83 -0
  20. data/lib/{plugins → ditz/plugins}/git.rb +57 -18
  21. data/lib/ditz/plugins/issue-claiming.rb +174 -0
  22. data/lib/ditz/plugins/issue-labeling.rb +161 -0
  23. data/lib/{util.rb → ditz/util.rb} +4 -0
  24. data/lib/{view.rb → ditz/view.rb} +0 -0
  25. data/lib/{views.rb → ditz/views.rb} +7 -4
  26. data/man/{ditz.1 → man1/ditz.1} +1 -1
  27. data/setup.rb +1585 -0
  28. data/share/ditz/blue-check.png +0 -0
  29. data/{lib → share/ditz}/component.rhtml +7 -5
  30. data/share/ditz/green-bar.png +0 -0
  31. data/share/ditz/green-check.png +0 -0
  32. data/share/ditz/index.rhtml +130 -0
  33. data/share/ditz/issue.rhtml +119 -0
  34. data/share/ditz/issue_table.rhtml +28 -0
  35. data/share/ditz/red-check.png +0 -0
  36. data/share/ditz/release.rhtml +98 -0
  37. data/share/ditz/style.css +226 -0
  38. data/share/ditz/unassigned.rhtml +23 -0
  39. data/share/ditz/yellow-bar.png +0 -0
  40. metadata +50 -28
  41. data/lib/index.rhtml +0 -113
  42. data/lib/issue.rhtml +0 -111
  43. data/lib/issue_table.rhtml +0 -33
  44. data/lib/model.rb +0 -208
  45. data/lib/plugins/issue-claiming.rb +0 -92
  46. data/lib/release.rhtml +0 -69
  47. data/lib/style.css +0 -127
  48. data/lib/trollop.rb +0 -518
  49. data/lib/unassigned.rhtml +0 -31
  50. data/lib/vendor/yaml_waml.rb +0 -28
@@ -6,16 +6,32 @@
6
6
 
7
7
  _ditz()
8
8
  {
9
- cur=${COMP_WORDS[COMP_CWORD]}
9
+ local cur=${COMP_WORDS[COMP_CWORD]}
10
+
10
11
  if [ $COMP_CWORD -eq 1 ]; then
11
- COMPREPLY=( $( compgen -W "$(ditz --commands)" $cur ) )
12
- elif [ $COMP_CWORD -eq 2 ]; then
13
- cmd=${COMP_WORDS[1]}
14
- COMPREPLY=( $( compgen -W "$(ditz "$cmd" '<options>' 2>/dev/null)" $cur ) )
15
- elif [ $COMP_CWORD -eq 3 ]; then
16
- cmd=${COMP_WORDS[1]}
17
- parm1=${COMP_WORDS[2]}
18
- COMPREPLY=( $( compgen -W "$(ditz "$cmd" "$parm1" '<options>' 2>/dev/null)" $cur ) )
12
+ # no command yet, show all commands
13
+ COMPREPLY=( $( compgen -W "$(ditz --commands)" -- $cur ) )
14
+
15
+ else
16
+ unset COMP_WORDS[COMP_CWORD] # remove last
17
+ unset COMP_WORDS[0] # remove first
18
+
19
+ # add options if applicable...
20
+ local options
21
+ if [ "${cur:0:1}" = '-' ]; then
22
+ # ...but only if at least a dash is given
23
+ case "${COMP_WORDS[1]}" in
24
+ add|add_reference|add_release|assign|close|comment|release|set_component|start|stop|unassign)
25
+ options="--comment --no-comment"
26
+ ;;
27
+ edit)
28
+ options="--comment --no-comment --silent"
29
+ ;;
30
+ esac
31
+ fi
32
+
33
+ # let ditz parse the commandline and print available completions, then append the options form above
34
+ COMPREPLY=( $( compgen -W "$(ditz "${COMP_WORDS[@]}" '<options>' 2>/dev/null) $options" -- $cur ) )
19
35
  fi
20
36
  }
21
37
 
@@ -1,9 +1,13 @@
1
+ require 'pathname'
2
+
1
3
  module Ditz
2
4
 
3
- VERSION = "0.4"
5
+ VERSION = "0.5"
6
+ attr_accessor :verbose
7
+ module_function :verbose, :verbose=
4
8
 
5
9
  def debug s
6
- puts "# #{s}" if $opts[:verbose]
10
+ puts "# #{s}" if $verbose || Ditz::verbose
7
11
  end
8
12
  module_function :debug
9
13
 
@@ -46,11 +50,52 @@ def find_ditz_file fn
46
50
  File.expand_path File.join(dir, fn)
47
51
  end
48
52
 
49
- module_function :home_dir, :find_dir_containing, :find_ditz_file
53
+ def load_plugins fn
54
+ Ditz::debug "loading plugins from #{fn}"
55
+ plugins = YAML::load_file fn
56
+ plugins.each do |p|
57
+ fn = Ditz::find_ditz_file "ditz/plugins/#{p}.rb"
58
+ Ditz::debug "loading plugin #{p.inspect} from #{fn}"
59
+ require File.expand_path(fn)
60
+ end
61
+ plugins
62
+ end
63
+
64
+ module_function :home_dir, :find_dir_containing, :find_ditz_file, :load_plugins
50
65
  end
51
66
 
52
- require 'model-objects'
53
- require 'operator'
54
- require 'views'
55
- require 'hook'
67
+ # Git-style automatic pagination of all output.
68
+ # Call run_pa ger from any opperator needing pagination.
69
+ # Yoinked from http://nex-3.com/posts/73-git-style-automatic-paging-in-ruby#comments
70
+ def run_pager
71
+ return if PLATFORM =~ /win32/
72
+ return unless STDOUT.tty?
73
+
74
+ read, write = IO.pipe
75
+
76
+ unless Kernel.fork # Child process
77
+ STDOUT.reopen(write)
78
+ STDERR.reopen(write) if STDERR.tty?
79
+ read.close
80
+ write.close
81
+ return
82
+ end
83
+
84
+ # Parent process, become pager
85
+ STDIN.reopen(read)
86
+ read.close
87
+ write.close
88
+
89
+ ENV['LESS'] ||= 'FSRX' # Don't page if the input is short enough, unless
90
+ # the user already have a LESS variable.
91
+
92
+ Kernel.select [STDIN] # Wait until we have input before we start the pager
93
+ pager = ENV['PAGER'] || 'less'
94
+ exec pager rescue exec "/bin/sh", "-c", pager
95
+ end
56
96
 
97
+ require 'ditz/model-objects'
98
+ require 'ditz/operator'
99
+ require 'ditz/views'
100
+ require 'ditz/hook'
101
+ require 'ditz/file-storage'
@@ -0,0 +1,54 @@
1
+ module Ditz
2
+
3
+ ## stores ditz database on disk
4
+ class FileStorage
5
+ PROJECT_FN = "project.yaml"
6
+ ISSUE_FN_GLOB = "issue-*.yaml"
7
+
8
+ def ISSUE_TO_FN i; "issue-#{i.id}.yaml" end
9
+
10
+ def initialize base_dir
11
+ @base_dir = base_dir
12
+ @project_fn = File.join @base_dir, PROJECT_FN
13
+ end
14
+
15
+ def load
16
+ Ditz::debug "loading project from #{@project_fn}"
17
+ project = Project.from @project_fn
18
+
19
+ fn = File.join @base_dir, ISSUE_FN_GLOB
20
+ Ditz::debug "loading issues from #{fn}"
21
+ project.issues = Dir[fn].map { |fn| Issue.from fn }
22
+ Ditz::debug "found #{project.issues.size} issues"
23
+
24
+ project.issues.each { |i| i.project = project }
25
+ project
26
+ end
27
+
28
+ def save project
29
+ dirty = false
30
+ dirty = project.each_modelobject { |o| break true if o.changed? }
31
+ if dirty
32
+ Ditz::debug "project is dirty, saving #{@project_fn}"
33
+ project.save! @project_fn
34
+ end
35
+
36
+ changed_issues = project.issues.select { |i| i.changed? }
37
+ changed_issues.each do |i|
38
+ fn = filename_for_issue i
39
+ Ditz::debug "issue #{i.name} is dirty, saving #{fn}"
40
+ i.save! fn
41
+ end
42
+
43
+ project.deleted_issues.each do |i|
44
+ fn = filename_for_issue i
45
+ Ditz::debug "issue #{i.name} has been deleted, deleting #{fn}"
46
+ FileUtils.rm fn
47
+ end
48
+ end
49
+
50
+ def filename_for_issue i; File.join @base_dir, ISSUE_TO_FN(i) end
51
+ def filename_for_project; @project_fn end
52
+ end
53
+
54
+ end
@@ -57,7 +57,7 @@ EOS
57
57
  files = dirs.map { |d| Dir[File.join(d, "*.rb")] }.flatten
58
58
  files.each do |fn|
59
59
  Ditz::debug "loading hook file #{fn}"
60
- load fn
60
+ require File.expand_path(fn)
61
61
  end
62
62
  end
63
63
 
@@ -48,16 +48,54 @@ class ErbHtml
48
48
  raise ArgumentError, "no link for #{o.inspect}" unless dest
49
49
  "<a href=\"#{dest}\">#{name}</a>"
50
50
  end
51
- def fancy_issue_link_for i
52
- "<span class=\"issuestatus_#{i.status}\">" + link_to(i, "[#{i.title}]") + "</span>"
51
+
52
+ def issue_status_img_for i, opts={}
53
+ fn, title = if i.closed?
54
+ case i.disposition
55
+ when :fixed; ["green-check.png", "fixed"]
56
+ when :wontfix; ["red-check.png", "won't fix"]
57
+ when :reorg; ["blue-check.png", "reorganized"]
58
+ end
59
+ elsif i.in_progress?
60
+ ["green-bar.png", "in progress"]
61
+ elsif i.paused?
62
+ ["yellow-bar.png", "paused"]
63
+ end
64
+
65
+ return "" unless fn
66
+
67
+ args = {:src => fn, :alt => title, :title => title}
68
+ args[:class] = opts[:class] if opts[:class]
69
+
70
+ "<img " + args.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") + "/>"
53
71
  end
54
72
 
55
- def link_issue_names project, s
73
+ def issue_link_for i, opts={}
74
+ link = if opts[:inline]
75
+ "<span class=\"inline-issue-link\">" + link_to(i, "issue <span class=\"id\">#{i.id[0,8]}</span>: #{i.title}") + "</span>"
76
+ else
77
+ link_to i, i.title
78
+ end
79
+ link = link + " " + issue_status_img_for(i, :class => "inline-status-image") if opts[:status_image]
80
+ link
81
+ end
82
+
83
+ def link_issue_names project, s, opts={}
56
84
  project.issues.inject(s) do |s, i|
57
- s.gsub(/\b#{i.name}\b/, fancy_issue_link_for(i))
85
+ s.gsub(/\b#{i.name}\b/, issue_link_for(i, {:inline => true, :status_image => true}.merge(opts)))
58
86
  end
59
87
  end
60
88
 
89
+ def progress_meter p, size=50
90
+ done = (p * size).to_i
91
+ undone = [size - done, 0].max
92
+ "<span class='progress-meter'><span class='progress-meter-done'>" +
93
+ ("&nbsp;" * done) +
94
+ "</span><span class='progress-meter-undone'>" +
95
+ ("&nbsp;" * undone) +
96
+ "</span></span>"
97
+ end
98
+
61
99
  ## render a nested ERB
62
100
  alias :render :render_template
63
101
 
@@ -1,5 +1,5 @@
1
1
  require 'tempfile'
2
- require "util"
2
+ require "ditz/util"
3
3
 
4
4
  class Numeric
5
5
  def to_pretty_s
@@ -57,7 +57,9 @@ module Lowline
57
57
  yield f
58
58
  f.close
59
59
 
60
- editor = ENV["EDITOR"] || "/usr/bin/vi"
60
+ editor = ENV["EDITOR"]
61
+ editor ||= "/usr/bin/sensible-editor" if File.exist?("/usr/bin/sensible-editor")
62
+ editor ||= "/usr/bin/vi"
61
63
  cmd = "#{editor} #{f.path.inspect}"
62
64
 
63
65
  mtime = File.mtime f.path
@@ -98,11 +100,10 @@ module Lowline
98
100
 
99
101
  def ask_via_editor q, default=nil
100
102
  fn = run_editor do |f|
103
+ f.puts(default || "")
101
104
  f.puts q.gsub(/^/, "## ")
102
105
  f.puts "##"
103
- f.puts "## Enter your text below. Lines starting with a '#' will be ignored."
104
- f.puts
105
- f.puts default if default
106
+ f.puts "## Enter your text above. Lines starting with a '#' will be ignored."
106
107
  end
107
108
  return unless fn
108
109
  IO.read(fn).gsub(/^#.*$/, "").multistrip
@@ -139,6 +140,15 @@ module Lowline
139
140
  ans.multistrip
140
141
  end
141
142
 
143
+ def can_run_editor?
144
+ !ENV["EDITOR"].nil? || File.exist?("/usr/bin/sensible-editor") || File.exist?("/usr/bin/vi")
145
+ end
146
+
147
+ def ask_multiline_smartly q
148
+ can_run_editor? ? ask_via_editor(q) : ask_multiline(q)
149
+ end
150
+
151
+
142
152
  def ask_yon q
143
153
  while true
144
154
  print "#{q} (y/n): "
@@ -175,8 +185,15 @@ module Lowline
175
185
  stuff
176
186
  end
177
187
 
178
- def ask_for_selection stuff, name, to_string=:to_s
179
- puts "Choose a #{name}:"
188
+ def ask_for_selection stuff, name, to_string=:to_s, many=false
189
+ if many
190
+ return [] if stuff.empty?
191
+ name = name.pluralize(2, false)
192
+ puts "Choose one or more #{name} (comma separated list):"
193
+ else
194
+ return nil if stuff.empty?
195
+ puts "Choose a #{name}:"
196
+ end
180
197
  stuff.each_with_index do |c, i|
181
198
  pretty = case to_string
182
199
  when block_given? && to_string # heh
@@ -191,12 +208,15 @@ module Lowline
191
208
  puts " #{i + 1}) #{pretty}"
192
209
  end
193
210
 
194
- j = while true
195
- i = ask "#{name.capitalize} (1--#{stuff.size})"
196
- break i.to_i if i && (1 .. stuff.size).member?(i.to_i)
211
+ js = while true
212
+ is = ask "#{name.capitalize} (1--#{stuff.size})"
213
+ next unless is
214
+ is = is.strip.split(/\s*,\s*/).map { |i| i.to_i }
215
+ break is if is.all? { |i| (1 .. stuff.size).member?(i) }
197
216
  end
198
217
 
199
- stuff[j - 1]
218
+ ss = js.map { |j| stuff[j - 1] }
219
+ (many)? ss : ss.first
200
220
  end
201
221
  end
202
222
 
@@ -1,4 +1,4 @@
1
- require 'model'
1
+ require 'ditz/model'
2
2
 
3
3
  module Ditz
4
4
 
@@ -36,17 +36,37 @@ end
36
36
  class Project < ModelObject
37
37
  class Error < StandardError; end
38
38
 
39
- attr_accessor :pathname
40
-
41
- field :name, :default_generator => lambda { File.basename(Dir.pwd) }
39
+ field :name, :prompt => "Project name", :default_generator => lambda { File.basename(Dir.pwd) }
42
40
  field :version, :default => Ditz::VERSION, :ask => false
43
- field :components, :multi => true, :generator => :get_components
41
+ field :components, :multi => true, :interactive_generator => :get_components
44
42
  field :releases, :multi => true, :ask => false
45
43
 
44
+ attr_accessor :pathname
45
+
46
46
  ## issues are not model fields proper, so we build up their interface here.
47
- attr_accessor :issues
48
- def add_issue issue; added_issues << issue; issues << issue end
49
- def drop_issue issue; deleted_issues << issue if issues.delete issue end
47
+ attr_reader :issues
48
+ def issues= issues
49
+ @issues = issues
50
+ @issues.each { |i| i.project = self }
51
+ assign_issue_names!
52
+ issues
53
+ end
54
+
55
+ def add_issue issue
56
+ added_issues << issue
57
+ issues << issue
58
+ issue.project = self
59
+ assign_issue_names!
60
+ issue
61
+ end
62
+
63
+ def drop_issue issue
64
+ if issues.delete issue
65
+ deleted_issues << issue
66
+ assign_issue_names!
67
+ end
68
+ end
69
+
50
70
  def added_issues; @added_issues ||= [] end
51
71
  def deleted_issues; @deleted_issues ||= [] end
52
72
 
@@ -63,7 +83,7 @@ EOS
63
83
 
64
84
  def issues_for ident
65
85
  by_name = issues.find { |i| i.name == ident }
66
- by_name ? [by_name] : issues.select { |i| i.id =~ /^#{ident}/ }
86
+ by_name ? [by_name] : issues.select { |i| i.id =~ /^#{Regexp::escape ident}/ }
67
87
  end
68
88
 
69
89
  def component_for component_name
@@ -107,6 +127,12 @@ EOS
107
127
  raise Error, "more than one release named #{dup.inspect}"
108
128
  end
109
129
  end
130
+
131
+ def self.from *a
132
+ p = super(*a)
133
+ p.validate!
134
+ p
135
+ end
110
136
  end
111
137
 
112
138
  class Issue < ModelObject
@@ -114,9 +140,9 @@ class Issue < ModelObject
114
140
 
115
141
  field :title
116
142
  field :desc, :prompt => "Description", :multiline => true
117
- field :type, :generator => :get_type
118
- field :component, :generator => :get_component
119
- field :release, :generator => :get_release
143
+ field :type, :interactive_generator => :get_type
144
+ field :component, :interactive_generator => :get_component
145
+ field :release, :interactive_generator => :get_release, :nil_ok => true
120
146
  field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
121
147
  field :status, :ask => false, :default => :unstarted
122
148
  field :disposition, :ask => false
@@ -194,6 +220,7 @@ class Issue < ModelObject
194
220
  def feature?; type == :feature end
195
221
  def unassigned?; release.nil? end
196
222
  def assigned?; !unassigned? end
223
+ def paused?; status == :paused end
197
224
 
198
225
  def start_work who, comment; change_status :in_progress, who, comment end
199
226
  def stop_work who, comment
@@ -216,27 +243,29 @@ class Issue < ModelObject
216
243
  end
217
244
  private :change_status
218
245
 
219
- def change hash, who, comment
246
+ def change hash, who, comment, silent
220
247
  what = []
221
248
  if title != hash[:title]
222
- what << "changed title"
249
+ what << "title"
223
250
  self.title = hash[:title]
224
251
  end
225
252
 
226
253
  if desc != hash[:description]
227
- what << "changed description"
254
+ what << "description"
228
255
  self.desc = hash[:description]
229
256
  end
230
257
 
231
258
  if reporter != hash[:reporter]
232
- what << "changed reporter"
259
+ what << "reporter"
233
260
  self.reporter = hash[:reporter]
234
261
  end
235
262
 
236
- unless what.empty?
237
- log what.join(", "), who, comment
263
+ unless what.empty? || silent
264
+ log "edited " + what.join(", "), who, comment
238
265
  true
239
266
  end
267
+
268
+ !what.empty?
240
269
  end
241
270
 
242
271
  def assign_to_release release, who, comment
@@ -289,15 +318,18 @@ end
289
318
  class Config < ModelObject
290
319
  field :name, :prompt => "Your name", :default_generator => :get_default_name
291
320
  field :email, :prompt => "Your email address", :default_generator => :get_default_email
292
- field :issue_dir, :ask => false, :default => "bugs"
321
+ field :issue_dir, :prompt => "Directory to store issues state in", :default => ".ditz"
293
322
 
294
323
  def user; "#{name} <#{email}>" end
295
324
 
296
325
  def get_default_name
297
326
  require 'etc'
298
327
 
299
- name = Etc.getpwnam(ENV["USER"])
300
- name = name ? name.gecos.split(/,/).first : ""
328
+ name = if ENV["USER"]
329
+ pwent = Etc.getpwnam ENV["USER"]
330
+ pwent ? pwent.gecos.split(/,/).first : nil
331
+ end
332
+ name || "Ditz User"
301
333
  end
302
334
 
303
335
  def get_default_email