bugzyrb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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