bugzyrb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/bugzyrb.rb ADDED
@@ -0,0 +1,1131 @@
1
+ #!/usr/bin/env ruby
2
+ =begin
3
+ * Name: bugzyrb.rb
4
+ * Description: a command line bug tracker uses sqlite3 (port of bugzy.sh)
5
+ * Author: rkumar
6
+ * Date: 2010-06-24
7
+ * License: Ruby License
8
+ * Now requires subcommand gem
9
+
10
+ =end
11
+ require 'rubygems'
12
+ $:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
13
+ require 'common/colorconstants'
14
+ require 'common/sed'
15
+ require 'common/cmdapp'
16
+ require 'subcommand'
17
+ require 'sqlite3'
18
+ require 'highline/import'
19
+ require 'common/db'
20
+ include ColorConstants
21
+ include Sed
22
+ include Cmdapp
23
+ include Subcommands
24
+ include Database
25
+
26
+ PRI_A = YELLOW + BOLD
27
+ PRI_B = WHITE + BOLD
28
+ PRI_C = GREEN + BOLD
29
+ PRI_D = CYAN + BOLD
30
+ VERSION = "0.0.0"
31
+ DATE = "2010-06-24"
32
+ APPNAME = File.basename($0)
33
+ AUTHOR = "rkumar"
34
+
35
+ class Bugzy
36
+ # This class is responsible for all todo task related functionality.
37
+ #
38
+ # == Create a file
39
+ #
40
+ # $ bugzyrb init
41
+ #
42
+ # The above creates a bugzy.sqlite file
43
+ #
44
+ # Adding a task is the first operation.
45
+ # $ bugzyrb add "Create a project in rubyforge"
46
+ # $ bugzyrb add "Update Rakefile with project name"
47
+ #
48
+ # == List tasks
49
+ # To list open/unstarted tasks:
50
+ # $ bugzyrb
51
+ # To list closed tasks also:
52
+ # $ bugzyrb --show-all
53
+ #
54
+ # If you are located elsewhere, give directory name:
55
+ # $ bugzyrb -d ~/
56
+ #
57
+ # == Close a task (mark as done)
58
+ # $ bugzyrb status close 1
59
+ #
60
+ # == Add priority
61
+ # $ bugzyrb pri A 2
62
+ #
63
+ # For more:
64
+ # $ bugzyrb --help
65
+ # $ bugzyrb --show-actions
66
+ #
67
+ # == TODO:
68
+ #
69
+ def initialize options, argv
70
+
71
+ @options = options
72
+ @argv = argv
73
+ @file = options[:file]
74
+ ## data is a 2 dim array: rows and fields. It contains each row of the file
75
+ # as an array of strings. The item number is space padded.
76
+ @data = []
77
+ init_vars
78
+ end
79
+ def init_vars
80
+ @app_default_action = "list" # TODO:
81
+ @file = @app_file_path = @options[:file] || "bugzy.sqlite"
82
+ #@app_serial_path = File.expand_path("~/serial_numbers")
83
+ @deleted_path = "todo_deleted.txt"
84
+ @todo_delim = "\t"
85
+ @appname = File.basename( Dir.getwd ) #+ ".#{$0}"
86
+ # in order to support the testing framework
87
+ t = Time.now
88
+ #ut = ENV["TODO_TEST_TIME"]
89
+ #t = Time.at(ut.to_i) if ut
90
+ @now = t.strftime("%Y-%m-%d %H:%M:%S")
91
+ @today = t.strftime("%Y-%m-%d")
92
+ @verbose = @options[:verbose]
93
+ # menu MENU
94
+ @valid_type = %w[bug enhancement feature task]
95
+ @valid_severity = %w[normal critical moderate]
96
+ @valid_status = %w[open started closed stopped canceled]
97
+ @valid_priority = %w[P1 P2 P3 P4 P5]
98
+ $prompt_desc = $prompt_type = $prompt_status = $prompt_severity = $prompt_assigned_to = true
99
+ $default_priority = nil
100
+ $default_type = "bug"
101
+ $default_severity = "normal"
102
+ $default_status = "open"
103
+ $default_priority = "P3"
104
+ $default_assigned_to = ""
105
+ $default_due = 5 # how many days in advance due date should be
106
+ #$bare = @options[:bare]
107
+ # we need to load the cfg file here, if given # , or else in home dir.
108
+ if @options[:config]
109
+ load @options[:config]
110
+ end
111
+ end
112
+ %w[type severity status priority].each do |f|
113
+ eval(
114
+ "def validate_#{f}(value)
115
+ @valid_#{f}.include? value
116
+ end"
117
+ )
118
+ end
119
+
120
+ # initialize the database in current dir
121
+ # should we add project and/or component ?
122
+ # schema - adding created_by for bug and comment and log, but how to get ?
123
+ # assuming created by will contain email id so longish.
124
+ def init args=nil
125
+ die "#{@file} already exist. Please delete if you wish to recreate." if File.exists? @file
126
+
127
+ @db = SQLite3::Database.new( @file )
128
+ sql = <<SQL
129
+
130
+ CREATE TABLE bugs (
131
+ id INTEGER PRIMARY KEY,
132
+ status VARCHAR(10) NOT NULL,
133
+ severity VARCHAR(10),
134
+ type VARCHAR(10),
135
+ assigned_to VARCHAR(10),
136
+ start_date DATE default CURRENT_DATE,
137
+ due_date DATE,
138
+ comment_count INTEGER default 0,
139
+ priority VARCHAR(10),
140
+ title VARCHAR(10) NOT NULL,
141
+ description TEXT,
142
+ fix TEXT,
143
+ created_by VARCHAR(60),
144
+ project VARCHAR(10),
145
+ component VARCHAR(10),
146
+ version VARCHAR(10),
147
+ date_created DATETIME default CURRENT_TIMESTAMP,
148
+ date_modified DATETIME default CURRENT_TIMESTAMP);
149
+
150
+ CREATE TABLE comments (
151
+ rowid INTEGER PRIMARY KEY,
152
+ id INTEGER NOT NULL ,
153
+ comment TEXT NOT NULL,
154
+ created_by VARCHAR(60),
155
+ date_created DATETIME default CURRENT_TIMESTAMP);
156
+
157
+ CREATE TABLE log (
158
+ rowid INTEGER PRIMARY KEY,
159
+ id INTEGER ,
160
+ field VARCHAR(15),
161
+ log TEXT,
162
+ created_by VARCHAR(60),
163
+ date_created DATETIME default CURRENT_TIMESTAMP);
164
+
165
+ SQL
166
+
167
+ ret = @db.execute_batch( sql )
168
+ # execute batch only returns nil
169
+ message "#{@file} created." if File.exists? @file
170
+ text = <<-TEXT
171
+ If you wish to associate projects and/or components and versions to an issue,
172
+ please add the same in the database using:
173
+
174
+ project add <NAME>
175
+ component add <PROJECTNAME> <COMPONENT>
176
+ version add <PROJECTNAME> <VERSION>
177
+ TEXT
178
+ message text
179
+
180
+ 0
181
+ end
182
+ def get_db
183
+ @db ||= DB.new @file
184
+ end
185
+ # returns default due date for add or qadd
186
+ # @return [Date] due date
187
+ def default_due_date
188
+ #Date.parse(future_date($default_due).to_s[0..10]) # => converts to a Date object
189
+ Date.today + $default_due
190
+ end
191
+ ##
192
+ # quick add which does not prompt user at all, only title is required on commandline
193
+ # all other fields will go in as defaults
194
+ # One may override defaults by specifying options
195
+ def qadd args
196
+ die "Title required by qadd" if args.nil? or args.empty?
197
+ db = get_db
198
+ body = {}
199
+ body['title'] = args.join " "
200
+ body['type'] = @options[:type] || $default_type
201
+ body['severity'] = @options[:severity] || $default_severity
202
+ body['status'] = @options[:status] || $default_status
203
+ body['priority'] = @options[:priority] || $default_priority
204
+ body['assigned_to'] = @options[:assigned_to]
205
+ #comment_count = 0
206
+ #body['description = nil
207
+ #fix = nil
208
+ body['start_date'] = @now
209
+ body['due_date'] = default_due_date
210
+ #rowid = db.bugs_insert(status, severity, type, assigned_to, start_date, due_date, comment_count, priority, title, description, fix)
211
+ rowid = db.table_insert_hash("bugs", body)
212
+ puts "Issue #{rowid} created"
213
+ type = body['type']
214
+ title = body['title']
215
+ logid = db.sql_logs_insert rowid, "create", "#{rowid} #{type}: #{title}"
216
+ body["id"] = rowid
217
+ mail_issue body
218
+ 0
219
+ end
220
+
221
+ ##
222
+ # add an issue or bug
223
+ # @params [Array] text of bug (ARGV), will be concatenated into single string
224
+ # @return [0,1] success or fail
225
+ # TODO: users should be able to switch on or off globals, and pass / change defaults
226
+ # TODO: reading environ ENV and config file.
227
+ def add args
228
+ db = get_db
229
+ if args.empty?
230
+ print "Enter a short summary: "
231
+ STDOUT.flush
232
+ text = gets.chomp
233
+ if text.empty?
234
+ exit ERRCODE
235
+ end
236
+ else
237
+ text = args.join " "
238
+ end
239
+ # convert actual newline to C-a. slash n's are escapes so echo -e does not muck up.
240
+ #atitle=$( echo "$atitle" | tr -cd '\40-\176' )
241
+ text.tr! "\n", ''
242
+ title = text
243
+ desc = nil
244
+ if $prompt_desc
245
+ message "Enter a detailed description (. to exit): "
246
+ desc = get_lines
247
+ end
248
+ message "You entered #{desc}"
249
+ type = $default_type || "bug"
250
+ severity = $default_severity || "normal"
251
+ status = $default_status || "open"
252
+ priority = $default_priority || "P3"
253
+ if $prompt_type
254
+ type = _choice("Select type:", %w[bug enhancement feature task] )
255
+ message "You selected #{type}"
256
+ end
257
+ if $prompt_severity
258
+ severity = _choice("Select severity:", %w[normal critical moderate] )
259
+ message "You selected #{severity}"
260
+ end
261
+ if $prompt_status
262
+ status = _choice("Select status:", %w[open started closed stopped canceled] )
263
+ message "You selected #{status}"
264
+ end
265
+ if $prompt_assigned_to
266
+ message "Assign to:"
267
+ assigned_to = $stdin.gets.chomp
268
+ message "You selected #{assigned_to}"
269
+ else
270
+ assigned_to = $default_assigned_to
271
+ end
272
+ project = component = version = nil
273
+ # project
274
+ if $use_project
275
+ project = user_input('project', $prompt_project, nil, $valid_project, $default_project)
276
+ end
277
+ if $use_component
278
+ component = user_input('component', $prompt_component, nil, $valid_component, $default_component)
279
+ end
280
+ if $use_version
281
+ version = user_input('version', $prompt_version, nil, $valid_version, $default_version)
282
+ end
283
+
284
+ start_date = @now
285
+ due_date = default_due_date
286
+ comment_count = 0
287
+ priority ||= "P3"
288
+ description = desc
289
+ fix = nil #"Some long text"
290
+ #date_created = @now
291
+ #date_modified = @now
292
+ body = {}
293
+ body["title"]=title
294
+ body["description"]=description
295
+ body["type"]=type
296
+ body["start_date"]=start_date.to_s
297
+ body["due_date"]=due_date.to_s
298
+ body["priority"]=priority
299
+ body["severity"]=severity
300
+ body["assigned_to"]=assigned_to
301
+ body["created_by"] = $default_user
302
+ # only insert if its wanted by user
303
+ body["project"]=project if $use_project
304
+ body["component"]=component if $use_component
305
+ body["version"]=version if $use_version
306
+
307
+ #rowid = db.bugs_insert(status, severity, type, assigned_to, start_date, due_date, comment_count, priority, title, description, fix)
308
+ rowid = db.table_insert_hash("bugs", body)
309
+ puts "Issue #{rowid} created"
310
+ logid = db.sql_logs_insert rowid, "create", "#{rowid} #{type}: #{title}"
311
+ # send an email of some sort needs improbement FIXME
312
+ #printable = %w[ title description status severity type assigned_to start_date due_date priority fix ]
313
+ body["id"] = rowid
314
+ mail_issue body
315
+
316
+ 0
317
+ end
318
+ def mail_issue row, emailid=nil
319
+ emailid ||= $default_user
320
+ body = <<TEXT
321
+ Id : #{row['id']}
322
+ Title : #{row['title']}
323
+ Description : #{row['description']}
324
+ Type : #{row['type']}
325
+ Start Date : #{row['start_date']}
326
+ Due Date : #{row['due_date']}
327
+ Priority : #{row['priority']}
328
+ Severity : #{row['severity']}
329
+ Assigned To : #{row['assigned_to']}
330
+ TEXT
331
+ title = "#{row['id']}: #{row['title']} "
332
+ require 'tempfile'
333
+ temp = Tempfile.new "bugzy"
334
+ File.open(temp,"w"){ |f| f.write body }
335
+
336
+ #cmd = %Q{ echo "#{body}" | mail -s "#{title}" "#{emailid}" }
337
+ # cat is not portable please change
338
+ cmd = %Q{ cat #{temp.path} | mail -s "#{title}" "#{emailid}" }
339
+
340
+ $stderr.puts "executing: #{cmd}"
341
+ unless system(cmd)
342
+ $stderr.puts "Error executing #{cmd}"
343
+ $stderr.puts $?
344
+ end
345
+
346
+ end
347
+ ##
348
+ # view details of a single issue/bug
349
+ # @param [Array] ARGV, first element is issue number
350
+ # If no arg supplied then shows highest entry
351
+ def view args
352
+ db = get_db
353
+ id = args[0].nil? ? db.max_bug_id : args[0]
354
+ db, row = validate_id id
355
+ die "No data found for #{id}" unless row
356
+ puts "[#{row['type']} \##{row['id']}] #{row['title']}"
357
+ puts row['description']
358
+ puts
359
+ comment_count = 0
360
+ #puts row
361
+ row.each_pair { |name, val|
362
+ next if name == "project" && !$use_project
363
+ next if name == "version" && !$use_version
364
+ next if name == "component" && !$use_component
365
+ comment_count = val.to_i if name == "comment_count"
366
+ n = sprintf("%-15s", name);
367
+ puts "#{n} : #{val}"
368
+ }
369
+ puts
370
+ if comment_count > 0
371
+ puts "Comments :"
372
+ db.select_where "comments", "id", id do |r|
373
+ #puts r.join(" | ")
374
+ puts "(#{r['date_created']}) [ #{r['created_by']} ] #{r['comment']}"
375
+ #pp r
376
+ end
377
+ end
378
+ puts "Log:"
379
+ db.select_where "log", "id", id do |r|
380
+ #puts r.join(" | ")
381
+ puts "------- (#{r['date_created']}) ------"
382
+ puts "#{r['field']} [ #{r['created_by']} ] #{r['log']} "
383
+ #pp r
384
+ end
385
+ end
386
+ ## tried out a version of view that uses template replacement
387
+ # but can't do placement of second column -- it does not come aligned, so forget
388
+ # NOTE: use rdoc/template instead - can handle arrays
389
+ def view2 args
390
+ db = get_db
391
+ id = args[0].nil? ? db.max_bug_id : args[0]
392
+ db, row = validate_id id
393
+ die "No data found for #{id}" unless row
394
+ t = File.dirname(__FILE__) + "/common/" + "bug.tmpl"
395
+ template = File::read(t)
396
+ puts Cmdapp::template_replace(template, row)
397
+ #puts row
398
+ #puts "Comments:"
399
+ t = File.dirname(__FILE__) + "/common/" + "comment.tmpl"
400
+ template = File::read(t)
401
+ db.select_where "comments", "id", id do |r|
402
+ puts Cmdapp::template_replace(template, r)
403
+ #puts r.join(" | ")
404
+ #puts "(#{r['date_created']}) #{r['comment']}"
405
+ #pp r
406
+ end
407
+ end
408
+ def edit args
409
+ db = get_db
410
+ id = args[0].nil? ? db.max_bug_id : args[0]
411
+ row = db.sql_select_rowid "bugs", id
412
+ die "No data found for #{id}" unless row
413
+ editable = %w[ status severity type assigned_to start_date due_date priority title description fix ]
414
+ sel = _choice "Select field to edit", editable
415
+ print "You chose: #{sel}"
416
+ old = row[sel]
417
+ puts " Current value is: #{old}"
418
+ meth = "ask_#{sel}".to_sym
419
+ if respond_to? "ask_#{sel}".to_sym
420
+ str = send(meth, old)
421
+ else
422
+ print "Enter value: "
423
+ str = $stdin.gets.chomp
424
+ end
425
+ #str = old if str.nil? or str == ""
426
+ if str.nil? or str == old
427
+ message "Operation cancelled."
428
+ exit 0
429
+ end
430
+ message "Updating:"
431
+ message str
432
+ db.sql_update "bugs", id, sel, str
433
+ puts "Updated #{id}"
434
+ rowid = db.sql_logs_insert id, sel, "[#{id}] updated [#{sel}] with #{str[0..50]}"
435
+ 0
436
+ end
437
+ # deletes given issue
438
+ # @param [Array] id of issue
439
+ def delete args
440
+ id = args.shift
441
+ if @options[:force]
442
+ db, row = validate_id id, false
443
+ db.sql_delete_bug id
444
+ exit 0
445
+ end
446
+ db, row = validate_id id, true
447
+ if agree("Delete this issue? ")
448
+ db.sql_delete_bug id
449
+ else
450
+ message "Operation cancelled"
451
+ end
452
+ 0
453
+ end
454
+ def viewlogs args
455
+ db = get_db
456
+ id = args[0].nil? ? db.max_bug_id : args[0]
457
+ row = db.sql_select_rowid "bugs", id
458
+ die "No data found for #{id}" unless row
459
+ puts "[#{row['type']} \##{row['id']}] #{row['title']}"
460
+ puts row['description']
461
+ puts
462
+ ctr = 0
463
+ db.select_where "log", "id", id do |r|
464
+ ctr += 1
465
+ puts "(#{r['date_created']}) #{r['field']} \t #{r['log']}"
466
+ #puts "(#{r['date_created']}) #{r['log']}"
467
+ end
468
+ message "No logs found" if ctr == 0
469
+ 0
470
+ end
471
+ ##
472
+ # lists issues
473
+ # @param [Array] argv: containing Strings containing matching or non-matching terms
474
+ # +term means title should include term
475
+ # -term means title should not include term
476
+ # @example
477
+ # list +testing
478
+ # list testing
479
+ # list crash -windows
480
+ # list -- -linux
481
+ def list args
482
+ # lets look at args as search words
483
+ incl = []
484
+ excl = []
485
+ args.each do |e|
486
+ if e[0] == '+'
487
+ incl << e[1..-1]
488
+ elsif e[0] == '-'
489
+ excl << e[1..-1]
490
+ else
491
+ incl << e
492
+ end
493
+ end
494
+ incl = nil if incl.empty?
495
+ excl = nil if excl.empty?
496
+ db = get_db
497
+ #db.run "select * from bugs " do |row|
498
+ #end
499
+ fields = "id,status,title,severity,priority,start_date,due_date"
500
+ if @options[:short]
501
+ fields = "id,status,title"
502
+ elsif @options[:long]
503
+ fields = "id,status,title,severity,priority,due_date,description"
504
+ end
505
+ rows = db.run "select #{fields} from bugs "
506
+
507
+ if incl
508
+ incl_str = incl.join "|"
509
+ r = Regexp.new incl_str
510
+ rows = rows.select { |row| row['title'] =~ r }
511
+ end
512
+ if excl
513
+ excl_str = excl.join "|"
514
+ r = Regexp.new excl_str
515
+ rows = rows.select { |row| row['title'] !~ r }
516
+ end
517
+ headings = fields.split ","
518
+ # if you want to filter output send a delimiter
519
+ if $bare
520
+ delim = @options[:delimiter] || "\t"
521
+ puts headings.join delim
522
+ rows.each do |e|
523
+ d = e['description']
524
+ e['description'] = d.gsub(/\n/," ") if d
525
+ puts e.join delim
526
+ end
527
+ else
528
+ # pretty output tabular format etc
529
+ require 'terminal-table/import'
530
+ #table = table(nil, *rows)
531
+ table = table(headings, *rows)
532
+ puts table
533
+ end
534
+ end
535
+ ## validate user entered id
536
+ # All methods should call this first.
537
+ # @param [Fixnum] id (actually can be String) to validate
538
+ # @return [Database, #execute] database handle
539
+ # @return [ResultSet] (arrayfields) data of row retrieved
540
+ # NOTE: exits (die) if no such row, so if calling in a loop ...
541
+ def validate_id id, print_header=false
542
+ db = get_db
543
+ #id ||= db.max_bug_id # if none supplied get highest - should we do this.
544
+ # no since the caller will not have id, will bomb later
545
+ row = db.sql_select_rowid "bugs", id
546
+ die "No data found for #{id}" unless row
547
+ if print_header
548
+ puts "[#{row['type']} \##{row['id']}] #{row['title']}"
549
+ puts row['description']
550
+ puts
551
+ end
552
+ return db, row
553
+ end
554
+ def putxx *args
555
+ puts "GOT:: #{args}"
556
+ end
557
+ def ask_type old=nil
558
+ type = _choice("Select type:", %w[bug enhancement feature task] )
559
+ end
560
+ def ask_severity old=nil
561
+ severity = _choice("Select severity:", %w[normal critical moderate] )
562
+ end
563
+ def ask_status old=nil
564
+ status = _choice("Select status:", %w[open started closed stopped canceled] )
565
+ end
566
+ def ask_priority old=nil
567
+ priority = _choice("Select priority:", %w[P1 P2 P3 P4 P5] )
568
+ end
569
+ def ask_fix old=nil
570
+ Cmdapp::edit_text old
571
+ end
572
+ def ask_description old=nil
573
+ Cmdapp::edit_text old
574
+ end
575
+ ##
576
+ # prompts user for a cooment to be attached to a issue/bug
577
+ def comment args #id, comment
578
+ id = args.shift
579
+ unless id
580
+ id = ask("Issue Id? ", Integer)
581
+ end
582
+ if !args.empty?
583
+ comment = args.join(" ")
584
+ else
585
+ message "Enter a comment (. to exit): "
586
+ comment = get_lines
587
+ end
588
+ die "Operation cancelled" if comment.nil? or comment.empty?
589
+ message "Comment is: #{comment}."
590
+ db, row = validate_id id
591
+ die "No issue found for #{id}" unless row
592
+ message "Adding comment to #{id}: #{row['title']}"
593
+ _comment db, id, comment
594
+ 0
595
+ end
596
+ # insert comment into database
597
+ # called from interactive, as well as "close" or others
598
+ def _comment db, id, text
599
+ rowid = db.sql_comments_insert id, text
600
+ puts "Comment #{rowid} created"
601
+ handle = db.db
602
+
603
+ commcount = handle.get_first_value( "select count(id) from comments where id = #{id};" )
604
+ commcount = commcount.to_i
605
+ db.sql_update "bugs", id, "comment_count", commcount
606
+ rowid = db.sql_logs_insert id, "comment",text[0..50]
607
+ end
608
+ # prompts user for a fix related to an issue
609
+ def fix args #id, fix
610
+ id = args.shift
611
+ unless id
612
+ id = ask("Issue Id? ", Integer)
613
+ end
614
+ if !args.empty?
615
+ text = args.join(" ")
616
+ else
617
+ message "Enter a fix (. to exit): "
618
+ text = get_lines
619
+ end
620
+ die "Operation cancelled" if text.nil? or text.empty?
621
+ message "fix is: #{text}."
622
+ db, row = validate_id id
623
+ message "Adding fix to #{id}: #{row['title']}"
624
+ _fix db, id, text
625
+ 0
626
+ end
627
+ def _fix db, id, text
628
+ db.sql_update "bugs", id, "fix", text
629
+ rowid = db.sql_logs_insert id, "fix", text[0..50]
630
+ end
631
+ ## internal method to log an action
632
+ # @param [Fixnum] id
633
+ # @param [String] column or create/delete for row
634
+ # @param [String] details such as content added, or content changed
635
+ def log id, field, text
636
+ id = args.shift
637
+ unless id
638
+ id = ask("Issue Id? ", Integer)
639
+ end
640
+ if !args.empty?
641
+ comment = args.join(" ")
642
+ else
643
+ message "Enter a comment (. to exit): "
644
+ comment = get_lines
645
+ end
646
+ die "Operation cancelled" if comment.nil? or comment.empty?
647
+ message "Comment is: #{comment}."
648
+ db = get_db
649
+ row = db.sql_select_rowid "bugs", id
650
+ die "No issue found for #{id}" unless row
651
+ message "Adding comment to #{id}: #{row['title']}"
652
+ rowid = db.sql_logs_insert id, field, log
653
+ puts "Comment #{rowid} created"
654
+ 0
655
+ end
656
+
657
+ ##
658
+ # change value of given column
659
+ # This is typically called internally so the new value will be validated.
660
+ # We can also do a validation against an array
661
+ # @param [String] column name
662
+ # @param [String] new value
663
+ # @param [Array] array of id's to close (argv)
664
+ # @return [0] for success
665
+ def change_value field="status", value="closed", args
666
+ #field = "status"
667
+ #value = "closed"
668
+ meth = "validate_#{field}".to_sym
669
+ if respond_to? meth
670
+ #bool = send("validate_#{field}".to_sym, value)
671
+ bool = send(meth, value)
672
+ die "#{value} is not valid for #{field}" unless bool
673
+ end
674
+ args.each do |id|
675
+ db, row = validate_id id
676
+ curr_status = row[field]
677
+ # don't update if already closed
678
+ if curr_status != value
679
+ db.sql_update "bugs", id, field, value
680
+ puts "Updated #{id}"
681
+ rowid = db.sql_logs_insert id, field, "[#{id}] updated [#{field}] with #{value}"
682
+ else
683
+ message "#{id} already #{value}"
684
+ end
685
+ _comment(db, id, @options[:comment]) if @options[:comment]
686
+ _fix(db, id, @options[:fix]) if @options[:fix]
687
+ end
688
+ 0
689
+ end
690
+ # close an issue (changes status of issue/s)
691
+ # @param [Array] array of id's to close (argv)
692
+ # @return [0] for success
693
+ def close args
694
+ change_value "status", "closed", args
695
+ 0
696
+ end
697
+
698
+ # start an issue (changes status of issue/s)
699
+ # @param [Array] array of id's to start (argv)
700
+ # @return [0] for success
701
+ def start args
702
+ change_value "status", "started", args
703
+ 0
704
+ end
705
+
706
+ ##
707
+ # get a date in the future giving how many days
708
+ # @param [Fixnum] how many days in the future
709
+ # @return [Time] Date object in future
710
+ # @example
711
+ # future_date(1).to_s[0..10]; # => creates a string object with only Date part, no time
712
+ # Date.parse(future_date(1).to_s[0..10]) # => converts to a Date object
713
+
714
+ def future_date days=1
715
+ Time.now() + (24 * 60 * 60 * days)
716
+ #(Time.now() + (24 * 60 * 60) * days).to_s[0..10];
717
+ end
718
+
719
+ ## prompt user for due date, called from edit
720
+ #def ask_due_date
721
+ #days = 1
722
+ #ask("Enter due date? ", Date) {
723
+ #|q| q.default = future_date(days).to_s[0..10];
724
+ #q.validate = lambda { |p| Date.parse(p) >= Date.parse(Time.now.to_s) };
725
+ #q.responses[:not_valid] = "Enter a date greater than today"
726
+ #}
727
+ #end
728
+ # prompt user for due date, called from edit
729
+ def ask_due_date
730
+ days = 1
731
+ today = Date.today
732
+ ask("Enter due date? ", Date) {
733
+ |q| q.default = today + days;
734
+ q.validate = lambda { |p| Date.parse(p) >= today };
735
+ q.responses[:not_valid] = "Enter a date greater than today"
736
+ }
737
+ end
738
+
739
+ def ask_start_date
740
+ ask("Enter start date? ", Date) {
741
+ #|q| q.default = Time.now.to_s[0..10];
742
+ |q| q.default = Date.today
743
+ }
744
+ end
745
+
746
+ def check_file filename=@app_file_path
747
+ File.exists?(filename) or die "#{filename} does not exist in this dir. Use 'add' to create an item first."
748
+ end
749
+ ##
750
+ # colorize each line, if required.
751
+ # However, we should put the colors in some Map, so it can be changed at configuration level.
752
+ #
753
+ def colorize # TODO:
754
+ colorme = @options[:colorize]
755
+ @data.each do |r|
756
+ if @options[:hide_numbering]
757
+ string = "#{r[1]} "
758
+ else
759
+ string = " #{r[0]} #{r[1]} "
760
+ end
761
+ if colorme
762
+ m=string.match(/\(([A-Z])\)/)
763
+ if m
764
+ case m[1]
765
+ when "A", "B", "C", "D"
766
+ pri = self.class.const_get("PRI_#{m[1]}")
767
+ #string = "#{YELLOW}#{BOLD}#{string}#{CLEAR}"
768
+ string = "#{pri}#{string}#{CLEAR}"
769
+ else
770
+ string = "#{NORMAL}#{GREEN}#{string}#{CLEAR}"
771
+ #string = "#{BLUE}\e[6m#{string}#{CLEAR}"
772
+ #string = "#{BLUE}#{string}#{CLEAR}"
773
+ end
774
+ else
775
+ #string = "#{NORMAL}#{string}#{CLEAR}"
776
+ # no need to put clear, let it be au natural
777
+ end
778
+ end # colorme
779
+ ## since we've added notes, we convert C-a to newline with spaces
780
+ # so it prints in next line with some neat indentation.
781
+ string.gsub!('', "\n ")
782
+ #string.tr! '', "\n"
783
+ puts string
784
+ end
785
+ end
786
+ # internal method for sorting on reverse of line (status, priority)
787
+ def sort # TODO:
788
+ fold_subtasks
789
+ if @options[:reverse]
790
+ @data.sort! { |a,b| a[1] <=> b[1] }
791
+ else
792
+ @data.sort! { |a,b| b[1] <=> a[1] }
793
+ end
794
+ unfold_subtasks
795
+ end
796
+ def grep # TODO:
797
+ r = Regexp.new @options[:grep]
798
+ #@data = @data.grep r
799
+ @data = @data.find_all {|i| i[1] =~ r }
800
+ end
801
+
802
+ ##
803
+ # separates args into tag or subcommand and items
804
+ # This allows user to pass e.g. a priority first and then item list
805
+ # or item list first and then priority.
806
+ # This can only be used if the tag or pri or status is non-numeric and the item is numeric.
807
+ def _separate args, pattern=nil #/^[a-zA-Z]/
808
+ tag = nil
809
+ items = []
810
+ args.each do |arg|
811
+ if arg =~ /^[0-9\.]+$/
812
+ items << arg
813
+ else
814
+ tag = arg
815
+ if pattern
816
+ die "#{@action}: #{arg} appears invalid." if arg !~ pattern
817
+ end
818
+ end
819
+ end
820
+ items = nil if items.empty?
821
+ return tag, items
822
+ end
823
+
824
+ def _choice prompt, choices
825
+ choose do |menu|
826
+ menu.prompt = prompt
827
+ menu.choices(*choices) do |n| return n; end
828
+ end
829
+ end
830
+ #
831
+ # take user input based on value of flag
832
+ # @param [String] column name
833
+ # @param [Boolean, Symbol] true, false, :freeform, :choice
834
+ # @param [String, nil] text to prompt
835
+ # @param [Array, nil] choices array or nil
836
+ # @param [Object] default value
837
+ # @return [String, nil] users choice
838
+ #
839
+ # TODO: should we not check for the ask_x methods and call them if present.
840
+ def user_input column, prompt_flag, prompt_text=nil, choices=nil, default=nil
841
+ if prompt_flag == true
842
+ prompt_flag = :freeform
843
+ prompt_flag = :choice if choices
844
+ end
845
+ case prompt_flag
846
+ when :freeform
847
+ prompt_text ||= "#{column.capitalize}? "
848
+ str = ask(prompt_text){ |q| q.default = default if default }
849
+ return str
850
+ when :choice
851
+ prompt_text ||= "Select #{column}:"
852
+ str = _choice(prompt_text, choices)
853
+ return str
854
+ when :multiline, :ml
855
+ return Cmdapp::edit_text default
856
+ when false
857
+ #return nil
858
+ return default
859
+ end
860
+ end
861
+ def test args=nil
862
+ if $use_project
863
+ project = user_input('project', $prompt_project, nil, $valid_project, $default_project)
864
+ puts project
865
+ end
866
+ if $use_component
867
+ component = user_input('component', $prompt_component, nil, $valid_component, $default_component)
868
+ puts component
869
+ end
870
+ end
871
+ ## prompts user for multiline input
872
+ # @param [String] text to use as prompt
873
+ # @return [String, nil] string with newlines or nil (if nothing entered).
874
+ #
875
+ def get_lines prompt=nil
876
+ #prompt ||= "Enter multiple lines, to quit enter . on empty line"
877
+ #message prompt
878
+ str = ""
879
+ while $stdin.gets # reads from STDIN
880
+ if $_.chomp == "."
881
+ break
882
+ end
883
+ str << $_
884
+ #puts "Read: #{$_}" # writes to STDOUT
885
+ end
886
+ return nil if str == ""
887
+ return str
888
+ end
889
+
890
+ def self.main args
891
+ ret = nil
892
+ begin
893
+ # http://www.ruby-doc.org/stdlib/libdoc/optparse/rdoc/classes/OptionParser.html
894
+ require 'optparse'
895
+ options = {}
896
+ options[:verbose] = false
897
+ options[:colorize] = true
898
+ $bare = false
899
+ # adding some env variable pickups, so you don't have to keep passing.
900
+ showall = ENV["TODO_SHOW_ALL"]
901
+ if showall
902
+ options[:show_all] = (showall == "0") ? false:true
903
+ end
904
+ plain = ENV["TODO_PLAIN"]
905
+ if plain
906
+ options[:colorize] = (plain == "0") ? false:true
907
+ end
908
+
909
+ Subcommands::global_options do |opts|
910
+ opts.banner = "Usage: #{APPNAME} [options] [subcommand [options]]"
911
+ opts.description = "Todo list manager"
912
+ #opts.separator ""
913
+ #opts.separator "Global options are:"
914
+ opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
915
+ options[:verbose] = v
916
+ end
917
+ opts.on("-c", "--config FILENAME", "config filename path") do |v|
918
+ v = File.expand_path v
919
+ options[:config] = v
920
+ if !File.exists? v
921
+ die "#{RED}#{v}: no such file #{CLEAR}"
922
+ end
923
+ end
924
+ opts.on("-d DIR", "--dir DIR", "Use bugs file in this directory") do |v|
925
+ require 'FileUtils'
926
+ dir = File.expand_path v
927
+ if File.directory? dir
928
+ options[:dir] = dir
929
+ # changing dir is important so that serial_number file is the current one.
930
+ FileUtils.cd dir
931
+ else
932
+ die "#{RED}#{v}: no such directory #{CLEAR}"
933
+ end
934
+ end
935
+ opts.on("--show-actions", "show actions ") do |v|
936
+ #todo = Bugzy.new(options, ARGV)
937
+ #todo.help nil - not working now that we've moved to subcommand
938
+ puts Subcommands::print_actions
939
+ exit 0
940
+ end
941
+
942
+ opts.on("--version", "Show version") do
943
+ version = Cmdapp::version_info || VERSION
944
+ puts "#{APPNAME} version #{version}, #{DATE}"
945
+ puts "by #{AUTHOR}. This software is under the GPL License."
946
+ exit 0
947
+ end
948
+ # No argument, shows at tail. This will print an options summary.
949
+ # Try it and see!
950
+ #opts.on("-h", "--help", "Show this message") do
951
+ #puts opts
952
+ #exit 0
953
+ #end
954
+ end
955
+ Subcommands::add_help_option
956
+ Subcommands::global_options do |opts|
957
+ opts.separator ""
958
+ opts.separator "Common Usage:"
959
+ opts.separator <<TEXT
960
+ #{APPNAME} add "Text ...."
961
+ #{APPNAME} list
962
+ #{APPNAME} start 1
963
+ #{APPNAME} close 1
964
+ TEXT
965
+ end
966
+
967
+ Subcommands::command :init do |opts|
968
+ opts.banner = "Usage: init [options]"
969
+ opts.description = "Create a datastore (sqlite3) for bugs/issues"
970
+ end
971
+
972
+ Subcommands::command :add, :a do |opts|
973
+ opts.banner = "Usage: add [options] TEXT"
974
+ opts.description = "Add a bug/issue."
975
+ opts.on("-f", "--[no-]force", "force verbosely") do |v|
976
+ options[:force] = v
977
+ end
978
+ opts.on("-P", "--project PROJECTNAME", "name of project ") { |v|
979
+ options[:project] = v
980
+ #options[:filter] = true
981
+ }
982
+ opts.on("-p", "--priority PRI", "priority code ") { |v|
983
+ options[:priority] = v
984
+ }
985
+ opts.on("-C", "--component COMPONENT", "component name ") { |v|
986
+ options[:component] = v
987
+ }
988
+ opts.on("--severity SEV", "severity code ") { |v|
989
+ options[:severity] = v
990
+ }
991
+ opts.on("-t", "--type TYPE", "type code ") { |v|
992
+ options[:type] = v
993
+ }
994
+ opts.on("--status STATUS", "status code ") { |v|
995
+ options[:status] = v
996
+ }
997
+ opts.on("-a","--assigned-to assignee", "assigned to whom ") { |v|
998
+ options[:assigned_to] = v
999
+ }
1000
+ end
1001
+ Subcommands::command :qadd, :a do |opts|
1002
+ opts.banner = "Usage: qadd [options] TITLE"
1003
+ opts.description = "Add an issue with no prompting"
1004
+ opts.on("-p", "--priority PRI", "priority code for add") { |v|
1005
+ options[:priority] = v
1006
+ }
1007
+ opts.on("-C", "--component COMPONENT", "component name for add or list") { |v|
1008
+ options[:component] = v
1009
+ }
1010
+ opts.on("--severity SEV", "severity code for add") { |v|
1011
+ options[:severity] = v
1012
+ }
1013
+ opts.on("-t","--type TYPE", "type code for add") { |v|
1014
+ options[:type] = v
1015
+ }
1016
+ opts.on("--status STATUS", "status code for add") { |v|
1017
+ options[:status] = v
1018
+ }
1019
+ opts.on("-a","--assigned-to assignee", "assigned to whom ") { |v|
1020
+ options[:assigned_to] = v
1021
+ }
1022
+ end
1023
+ Subcommands::command :view do |opts|
1024
+ opts.banner = "Usage: view [options] ISSUE_NO"
1025
+ opts.description = "View a given issue"
1026
+ end
1027
+ Subcommands::command :edit do |opts|
1028
+ opts.banner = "Usage: edit [options] ISSUE_NO"
1029
+ opts.description = "Edit a given issue"
1030
+ end
1031
+ Subcommands::command :comment do |opts|
1032
+ opts.banner = "Usage: comment [options] ISSUE_NO TEXT"
1033
+ opts.description = "Add comment a given issue"
1034
+ end
1035
+ Subcommands::command :test do |opts|
1036
+ opts.banner = "Usage: test [options] ISSUE_NO TEXT"
1037
+ opts.description = "Add test a given issue"
1038
+ end
1039
+ Subcommands::command :list do |opts|
1040
+ opts.banner = "Usage: list [options] search options"
1041
+ opts.description = "list issues"
1042
+ opts.on("--short", "short listing") { |v|
1043
+ options[:short] = v
1044
+ }
1045
+ opts.on("--long", "long listing") { |v|
1046
+ options[:long] = v
1047
+ }
1048
+ opts.on("-d","--delimiter STR", "listing delimiter") { |v|
1049
+ options[:delimiter] = v
1050
+ }
1051
+ opts.on("-b","--bare", "unformatted listing, for filtering") { |v|
1052
+ options[:bare] = v
1053
+ $bare = true
1054
+ }
1055
+ end
1056
+ Subcommands::command :viewlogs do |opts|
1057
+ opts.banner = "Usage: viewlogs [options] ISSUE_NO"
1058
+ opts.description = "view logs for an issue"
1059
+ end
1060
+ # XXX order of these 2 matters !! reverse and see what happens
1061
+ Subcommands::command :close, :clo do |opts|
1062
+ opts.banner = "Usage: clo [options] <ISSUENO>"
1063
+ opts.description = "Close an issue/s with fix or comment if given"
1064
+ opts.on("-f", "--fix TEXT", "add a fix while closing") do |v|
1065
+ options[:fix] = v
1066
+ end
1067
+ opts.on("-c", "--comment TEXT", "add a comment while closing") do |v|
1068
+ options[:comment] = v
1069
+ end
1070
+ end
1071
+ Subcommands::command :start, :sta do |opts|
1072
+ opts.banner = "Usage: sta [options] <ISSUENO>"
1073
+ opts.description = "Mark as started an issue/s with comment if given"
1074
+ #opts.on("-f", "--fix TEXT", "add a fix while closing") do |v|
1075
+ #options[:fix] = v
1076
+ #end
1077
+ opts.on("-c", "--comment TEXT", "add a comment while closing") do |v|
1078
+ options[:comment] = v
1079
+ end
1080
+ end
1081
+ #Subcommands::command :depri do |opts|
1082
+ #opts.banner = "Usage: depri [options] <TASK/s>"
1083
+ #opts.description = "Remove priority of task. \n\t bugzyrb depri <TASK>"
1084
+ #opts.on("-f", "--[no-]force", "force verbosely") do |v|
1085
+ #options[:force] = v
1086
+ #end
1087
+ #end
1088
+ Subcommands::command :delete, :del do |opts|
1089
+ opts.banner = "Usage: delete [options] <TASK/s>"
1090
+ opts.description = "Delete a task. \n\t bugzyrb delete <TASK>"
1091
+ opts.on("-f", "--[no-]force", "force - don't prompt") do |v|
1092
+ options[:force] = v
1093
+ end
1094
+ #opts.on("--recursive", "operate on subtasks also") { |v|
1095
+ #options[:recursive] = v
1096
+ #}
1097
+ end
1098
+ Subcommands::command :status do |opts|
1099
+ opts.banner = "Usage: status [options] <STATUS> <TASKS>"
1100
+ opts.description = "Change the status of a task. \t<STATUS> are open closed started pending hold next"
1101
+ opts.on("--recursive", "operate on subtasks also") { |v|
1102
+ options[:recursive] = v
1103
+ }
1104
+ end
1105
+ Subcommands::command :tag do |opts|
1106
+ opts.banner = "Usage: tag <TAG> <TASKS>"
1107
+ opts.description = "Add a tag to an item/s. "
1108
+ end
1109
+ #Subcommands::alias_command :open , "status","open"
1110
+ #Subcommands::alias_command :close , "status","closed"
1111
+ cmd = Subcommands::opt_parse()
1112
+ args.unshift cmd if cmd
1113
+
1114
+ if options[:verbose]
1115
+ p options
1116
+ print "ARGV: "
1117
+ p args #ARGV
1118
+ end
1119
+ #raise "-f FILENAME is mandatory" unless options[:file]
1120
+
1121
+ c = Bugzy.new(options, args)
1122
+ ret = c.run
1123
+ ensure
1124
+ end
1125
+ return ret
1126
+ end # main
1127
+ end # class Bugzy
1128
+
1129
+ if __FILE__ == $0
1130
+ exit Bugzy.main(ARGV)
1131
+ end