ursm-ditz 0.4 → 0.5
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog +15 -0
- data/INSTALL +20 -0
- data/LICENSE +674 -0
- data/Manifest.txt +40 -0
- data/PLUGINS.txt +140 -0
- data/README.txt +43 -27
- data/Rakefile +36 -3
- data/ReleaseNotes +6 -0
- data/bin/ditz +49 -63
- data/contrib/completion/ditz.bash +25 -9
- data/lib/ditz.rb +52 -7
- data/lib/ditz/file-storage.rb +54 -0
- data/lib/{hook.rb → ditz/hook.rb} +1 -1
- data/lib/{html.rb → ditz/html.rb} +42 -4
- data/lib/{lowline.rb → ditz/lowline.rb} +31 -11
- data/lib/{model-objects.rb → ditz/model-objects.rb} +53 -21
- data/lib/ditz/model.rb +321 -0
- data/lib/{operator.rb → ditz/operator.rb} +122 -67
- data/lib/ditz/plugins/git-sync.rb +83 -0
- data/lib/{plugins → ditz/plugins}/git.rb +57 -18
- data/lib/ditz/plugins/issue-claiming.rb +174 -0
- data/lib/ditz/plugins/issue-labeling.rb +161 -0
- data/lib/{util.rb → ditz/util.rb} +4 -0
- data/lib/{view.rb → ditz/view.rb} +0 -0
- data/lib/{views.rb → ditz/views.rb} +7 -4
- data/man/{ditz.1 → man1/ditz.1} +1 -1
- data/setup.rb +1585 -0
- data/share/ditz/blue-check.png +0 -0
- data/{lib → share/ditz}/component.rhtml +7 -5
- data/share/ditz/green-bar.png +0 -0
- data/share/ditz/green-check.png +0 -0
- data/share/ditz/index.rhtml +130 -0
- data/share/ditz/issue.rhtml +119 -0
- data/share/ditz/issue_table.rhtml +28 -0
- data/share/ditz/red-check.png +0 -0
- data/share/ditz/release.rhtml +98 -0
- data/share/ditz/style.css +226 -0
- data/share/ditz/unassigned.rhtml +23 -0
- data/share/ditz/yellow-bar.png +0 -0
- metadata +50 -28
- data/lib/index.rhtml +0 -113
- data/lib/issue.rhtml +0 -111
- data/lib/issue_table.rhtml +0 -33
- data/lib/model.rb +0 -208
- data/lib/plugins/issue-claiming.rb +0 -92
- data/lib/release.rhtml +0 -69
- data/lib/style.css +0 -127
- data/lib/trollop.rb +0 -518
- data/lib/unassigned.rhtml +0 -31
- 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
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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
|
|
data/lib/ditz.rb
CHANGED
@@ -1,9 +1,13 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
|
1
3
|
module Ditz
|
2
4
|
|
3
|
-
VERSION = "0.
|
5
|
+
VERSION = "0.5"
|
6
|
+
attr_accessor :verbose
|
7
|
+
module_function :verbose, :verbose=
|
4
8
|
|
5
9
|
def debug s
|
6
|
-
puts "# #{s}" if $
|
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
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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
|
@@ -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
|
-
|
52
|
-
|
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
|
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/,
|
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
|
+
(" " * done) +
|
94
|
+
"</span><span class='progress-meter-undone'>" +
|
95
|
+
(" " * 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"]
|
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
|
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
|
-
|
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
|
-
|
195
|
-
|
196
|
-
|
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
|
-
|
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, :
|
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
|
-
|
48
|
-
def
|
49
|
-
|
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, :
|
118
|
-
field :component, :
|
119
|
-
field :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 << "
|
249
|
+
what << "title"
|
223
250
|
self.title = hash[:title]
|
224
251
|
end
|
225
252
|
|
226
253
|
if desc != hash[:description]
|
227
|
-
what << "
|
254
|
+
what << "description"
|
228
255
|
self.desc = hash[:description]
|
229
256
|
end
|
230
257
|
|
231
258
|
if reporter != hash[:reporter]
|
232
|
-
what << "
|
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, :
|
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 =
|
300
|
-
|
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
|