ditz 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Changelog +2 -0
- data/README.txt +117 -0
- data/Rakefile +48 -0
- data/bin/ditz +77 -0
- data/lib/component.rhtml +18 -0
- data/lib/ditz.rb +13 -0
- data/lib/html.rb +41 -0
- data/lib/index.rhtml +83 -0
- data/lib/issue.rhtml +106 -0
- data/lib/issue_table.rhtml +29 -0
- data/lib/lowline.rb +150 -0
- data/lib/model-objects.rb +265 -0
- data/lib/model.rb +137 -0
- data/lib/operator.rb +408 -0
- data/lib/release.rhtml +67 -0
- data/lib/style.css +91 -0
- data/lib/unassigned.rhtml +27 -0
- data/lib/util.rb +33 -0
- metadata +79 -0
| @@ -0,0 +1,29 @@ | |
| 1 | 
            +
            <table>
         | 
| 2 | 
            +
            <% issues.sort_by { |i| i.sort_order }.each do |i| %>
         | 
| 3 | 
            +
              <tr>
         | 
| 4 | 
            +
                <td class="issuestatus_<%= i.status %>">
         | 
| 5 | 
            +
                  <%= i.status_string %><% if i.closed? %>: <%= i.disposition_string %><% end %>
         | 
| 6 | 
            +
                </td>
         | 
| 7 | 
            +
                <td class="issuename">
         | 
| 8 | 
            +
                  <%= link_to i, i.title %>
         | 
| 9 | 
            +
                  <%= i.bug? ? '(bug)' : '' %>
         | 
| 10 | 
            +
                </td>
         | 
| 11 | 
            +
                <% if show_release %>
         | 
| 12 | 
            +
                  <td class="issuerelease">
         | 
| 13 | 
            +
                    <% if i.release %>
         | 
| 14 | 
            +
                      <% r = project.release_for i.release %>
         | 
| 15 | 
            +
                      in <%= link_to r, "release #{i.release}" %>
         | 
| 16 | 
            +
                      (<%= r.status %>)
         | 
| 17 | 
            +
                    <% else %>
         | 
| 18 | 
            +
                    <% end %>
         | 
| 19 | 
            +
                  </td>
         | 
| 20 | 
            +
                <% end %>
         | 
| 21 | 
            +
                <% if show_component %>
         | 
| 22 | 
            +
                  <td class="issuecomponent">
         | 
| 23 | 
            +
                    component <%= link_to project.component_for(i.component), i.component %>
         | 
| 24 | 
            +
                  </td>
         | 
| 25 | 
            +
                <% end %>
         | 
| 26 | 
            +
              </tr>
         | 
| 27 | 
            +
            <% end %>
         | 
| 28 | 
            +
            </table>
         | 
| 29 | 
            +
             | 
    
        data/lib/lowline.rb
    ADDED
    
    | @@ -0,0 +1,150 @@ | |
| 1 | 
            +
            require 'util'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class Numeric
         | 
| 4 | 
            +
              def to_pretty_s
         | 
| 5 | 
            +
                if self < 20
         | 
| 6 | 
            +
                  %w(no one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen)[self]
         | 
| 7 | 
            +
                else
         | 
| 8 | 
            +
                  to_s
         | 
| 9 | 
            +
                end
         | 
| 10 | 
            +
              end
         | 
| 11 | 
            +
            end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            class String
         | 
| 14 | 
            +
              def ucfirst; self[0..0].upcase + self[1..-1] end
         | 
| 15 | 
            +
              def dcfirst; self[0..0].downcase + self[1..-1] end
         | 
| 16 | 
            +
              def blank?; self =~ /\A\s*\z/ end
         | 
| 17 | 
            +
              def underline; self + "\n" + ("-" * self.length) end
         | 
| 18 | 
            +
              def multiline prefix=""; blank? ? "" : "\n" + self.gsub(/^/, prefix) end
         | 
| 19 | 
            +
              def pluralize n; n.to_pretty_s + " " + (n == 1 ? self : self + "s") end # oh yeah
         | 
| 20 | 
            +
            end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            class Array
         | 
| 23 | 
            +
              def listify prefix=""
         | 
| 24 | 
            +
                return "" if empty?
         | 
| 25 | 
            +
                "\n" +
         | 
| 26 | 
            +
                  map_with_index { |x, i| x.to_s.gsub(/^/, "#{prefix}#{i + 1}. ") }.
         | 
| 27 | 
            +
                    join("\n")
         | 
| 28 | 
            +
              end
         | 
| 29 | 
            +
            end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
            class Time
         | 
| 32 | 
            +
              def pretty; strftime "%c" end
         | 
| 33 | 
            +
              def pretty_date; strftime "%Y-%m-%d" end
         | 
| 34 | 
            +
              def ago
         | 
| 35 | 
            +
                diff = (Time.now - self).to_i.abs
         | 
| 36 | 
            +
                if diff < 60
         | 
| 37 | 
            +
                  "second".pluralize diff
         | 
| 38 | 
            +
                elsif diff < 60*60*3
         | 
| 39 | 
            +
                  "minute".pluralize(diff / 60)
         | 
| 40 | 
            +
                elsif diff < 60*60*24*3
         | 
| 41 | 
            +
                  "hour".pluralize(diff / (60*60))
         | 
| 42 | 
            +
                elsif diff < 60*60*24*7*2
         | 
| 43 | 
            +
                  "day".pluralize(diff / (60*60*24))
         | 
| 44 | 
            +
                elsif diff < 60*60*24*7*8
         | 
| 45 | 
            +
                  "week".pluralize(diff / (60*60*24*7))
         | 
| 46 | 
            +
                elsif diff < 60*60*24*7*52
         | 
| 47 | 
            +
                  "month".pluralize(diff / (60*60*24*7*4))
         | 
| 48 | 
            +
                else
         | 
| 49 | 
            +
                  "year".pluralize(diff / (60*60*24*7*52))
         | 
| 50 | 
            +
                end
         | 
| 51 | 
            +
              end
         | 
| 52 | 
            +
            end
         | 
| 53 | 
            +
             | 
| 54 | 
            +
            module Lowline
         | 
| 55 | 
            +
              def ask q, opts={}
         | 
| 56 | 
            +
                default_s = case opts[:default]
         | 
| 57 | 
            +
                  when nil; nil
         | 
| 58 | 
            +
                  when ""; " (enter for none)"
         | 
| 59 | 
            +
                  else; " (enter for #{opts[:default].inspect})"
         | 
| 60 | 
            +
                end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                tail = case q
         | 
| 63 | 
            +
                  when /[:?]$/; " "
         | 
| 64 | 
            +
                  when /[:?]\s+$/; ""
         | 
| 65 | 
            +
                  else; ": "
         | 
| 66 | 
            +
                end
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                while true
         | 
| 69 | 
            +
                  print [q, default_s, tail].compact.join
         | 
| 70 | 
            +
                  ans = gets.strip
         | 
| 71 | 
            +
                  if opts[:default]
         | 
| 72 | 
            +
                    ans = opts[:default] if ans.blank?
         | 
| 73 | 
            +
                  else
         | 
| 74 | 
            +
                    next if ans.blank? && !opts[:empty_ok]
         | 
| 75 | 
            +
                  end
         | 
| 76 | 
            +
                  break ans unless (opts[:restrict] && ans !~ opts[:restrict])
         | 
| 77 | 
            +
                end
         | 
| 78 | 
            +
              end
         | 
| 79 | 
            +
             | 
| 80 | 
            +
              def ask_multiline q
         | 
| 81 | 
            +
                puts "#{q} (ctrl-d or . by itself to stop):"
         | 
| 82 | 
            +
                ans = ""
         | 
| 83 | 
            +
                while true
         | 
| 84 | 
            +
                  print "> "
         | 
| 85 | 
            +
                  line = gets
         | 
| 86 | 
            +
                  break if line =~ /^\.$/ || line.nil?
         | 
| 87 | 
            +
                  ans << line.strip + "\n"
         | 
| 88 | 
            +
                end
         | 
| 89 | 
            +
                ans.sub(/\n+$/, "")
         | 
| 90 | 
            +
              end
         | 
| 91 | 
            +
             | 
| 92 | 
            +
              def ask_yon q
         | 
| 93 | 
            +
                while true
         | 
| 94 | 
            +
                  print "#{q} (y/n): "
         | 
| 95 | 
            +
                  a = gets.strip
         | 
| 96 | 
            +
                  break a if a =~ /^[yn]$/i
         | 
| 97 | 
            +
                end =~ /y/i
         | 
| 98 | 
            +
              end
         | 
| 99 | 
            +
             | 
| 100 | 
            +
              def ask_for_many plural_name, name=nil
         | 
| 101 | 
            +
                name ||= plural_name.gsub(/s$/, "")
         | 
| 102 | 
            +
                stuff = []
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                while true
         | 
| 105 | 
            +
                  puts
         | 
| 106 | 
            +
                  puts "Current #{plural_name}:"
         | 
| 107 | 
            +
                  if stuff.empty?
         | 
| 108 | 
            +
                    puts "None!"
         | 
| 109 | 
            +
                  else
         | 
| 110 | 
            +
                    stuff.each_with_index { |c, i| puts "  #{i + 1}) #{c}" }
         | 
| 111 | 
            +
                  end
         | 
| 112 | 
            +
                  puts
         | 
| 113 | 
            +
                  ans = ask "(A)dd #{name}, (r)emove #{name}, or (d)one"
         | 
| 114 | 
            +
                  case ans
         | 
| 115 | 
            +
                  when "a", "A"
         | 
| 116 | 
            +
                    ans = ask "#{name.ucfirst} name", ""
         | 
| 117 | 
            +
                    stuff << ans unless ans =~ /^\s*$/
         | 
| 118 | 
            +
                  when "r", "R"
         | 
| 119 | 
            +
                    ans = ask "Remove which component? (1--#{stuff.size})"
         | 
| 120 | 
            +
                    stuff.delete_at(ans.to_i - 1) if ans
         | 
| 121 | 
            +
                  when "d", "D"
         | 
| 122 | 
            +
                    break
         | 
| 123 | 
            +
                  end
         | 
| 124 | 
            +
                end
         | 
| 125 | 
            +
                stuff
         | 
| 126 | 
            +
              end
         | 
| 127 | 
            +
             | 
| 128 | 
            +
              def ask_for_selection stuff, name, to_string=:to_s
         | 
| 129 | 
            +
                puts "Choose a #{name}:"
         | 
| 130 | 
            +
                stuff.each_with_index do |c, i|
         | 
| 131 | 
            +
                  pretty = case to_string
         | 
| 132 | 
            +
                  when Symbol
         | 
| 133 | 
            +
                    c.send to_string
         | 
| 134 | 
            +
                  when Proc
         | 
| 135 | 
            +
                    to_string.call c
         | 
| 136 | 
            +
                  else
         | 
| 137 | 
            +
                    raise ArgumentError, "unknown to_string argument type; expecting Proc or Symbol"
         | 
| 138 | 
            +
                  end
         | 
| 139 | 
            +
                  puts "  #{i + 1}) #{pretty}"
         | 
| 140 | 
            +
                end
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                j = while true
         | 
| 143 | 
            +
                  i = ask "#{name.ucfirst} (1--#{stuff.size})"
         | 
| 144 | 
            +
                  break i.to_i if i && (1 .. stuff.size).member?(i.to_i)
         | 
| 145 | 
            +
                end
         | 
| 146 | 
            +
             | 
| 147 | 
            +
                stuff[j - 1]
         | 
| 148 | 
            +
              end
         | 
| 149 | 
            +
            end
         | 
| 150 | 
            +
             | 
| @@ -0,0 +1,265 @@ | |
| 1 | 
            +
            require 'model'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Ditz
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            class Component < ModelObject
         | 
| 6 | 
            +
              field :name
         | 
| 7 | 
            +
              def name_prefix; @name.gsub(/\s+/, "-").downcase end
         | 
| 8 | 
            +
            end
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            class Release < ModelObject
         | 
| 11 | 
            +
              class Error < StandardError; end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              field :name
         | 
| 14 | 
            +
              field :status, :default => :unreleased, :ask => false
         | 
| 15 | 
            +
              field :release_time, :ask => false
         | 
| 16 | 
            +
              changes_are_logged
         | 
| 17 | 
            +
             | 
| 18 | 
            +
              def released?; self.status == :released end
         | 
| 19 | 
            +
              def unreleased?; !released? end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              def issues_from project; project.issues.select { |i| i.release == name } end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
              def release! project, who, comment
         | 
| 24 | 
            +
                raise Error, "already released" if released?
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                issues = issues_from project
         | 
| 27 | 
            +
                bad = issues.find { |i| i.open? }
         | 
| 28 | 
            +
                raise Error, "open issue #{bad.name} must be reassigned" if bad
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                self.release_time = Time.now
         | 
| 31 | 
            +
                self.status = :released
         | 
| 32 | 
            +
                log "released", who, comment
         | 
| 33 | 
            +
              end
         | 
| 34 | 
            +
            end
         | 
| 35 | 
            +
             | 
| 36 | 
            +
            class Project < ModelObject
         | 
| 37 | 
            +
              class Error < StandardError; end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
              field :name, :default_generator => lambda { File.basename(Dir.pwd) }
         | 
| 40 | 
            +
              field :version, :default => Ditz::VERSION, :ask => false
         | 
| 41 | 
            +
              field :issues, :multi => true, :ask => false
         | 
| 42 | 
            +
              field :components, :multi => true, :generator => :get_components
         | 
| 43 | 
            +
              field :releases, :multi => true, :ask => false
         | 
| 44 | 
            +
             | 
| 45 | 
            +
              def get_components
         | 
| 46 | 
            +
                puts <<EOS
         | 
| 47 | 
            +
            Issues can be tracked across the project as a whole, or the project can be
         | 
| 48 | 
            +
            split into components, and issues tracked separately for each component.
         | 
| 49 | 
            +
            EOS
         | 
| 50 | 
            +
                use_components = ask_yon "Track issues separately for different components?"
         | 
| 51 | 
            +
                comp_names = use_components ? ask_for_many("components") : []
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                ([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
         | 
| 54 | 
            +
              end
         | 
| 55 | 
            +
             | 
| 56 | 
            +
              def issue_for issue_name
         | 
| 57 | 
            +
                issues.find { |i| i.name == issue_name } or
         | 
| 58 | 
            +
                  raise Error, "has no issue with name #{issue_name.inspect}"
         | 
| 59 | 
            +
              end
         | 
| 60 | 
            +
             | 
| 61 | 
            +
              def component_for component_name
         | 
| 62 | 
            +
                components.find { |i| i.name == component_name } or
         | 
| 63 | 
            +
                  raise Error, "has no component with name #{component_name.inspect}"
         | 
| 64 | 
            +
              end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
              def release_for release_name
         | 
| 67 | 
            +
                releases.find { |i| i.name == release_name } or
         | 
| 68 | 
            +
                  raise Error, "has no release with name #{release_name.inspect}"
         | 
| 69 | 
            +
              end
         | 
| 70 | 
            +
             | 
| 71 | 
            +
              def issues_for_release release
         | 
| 72 | 
            +
                issues.select { |i| i.release == release.name }
         | 
| 73 | 
            +
              end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
              def issues_for_component component
         | 
| 76 | 
            +
                issues.select { |i| i.component == component.name }
         | 
| 77 | 
            +
              end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
              def unassigned_issues
         | 
| 80 | 
            +
                issues.select { |i| i.release.nil? }
         | 
| 81 | 
            +
              end
         | 
| 82 | 
            +
             | 
| 83 | 
            +
              def assign_issue_names!
         | 
| 84 | 
            +
                prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
         | 
| 85 | 
            +
                ids = components.map { |c| [c.name, 0] }.to_h
         | 
| 86 | 
            +
                issues.each do |i|
         | 
| 87 | 
            +
                  i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
         | 
| 88 | 
            +
                end
         | 
| 89 | 
            +
              end
         | 
| 90 | 
            +
             | 
| 91 | 
            +
              def validate!
         | 
| 92 | 
            +
                if(dup = components.map { |c| c.name }.first_duplicate)
         | 
| 93 | 
            +
                  raise Error, "more than one component named #{dup.inspect}"
         | 
| 94 | 
            +
                elsif(dup = releases.map { |r| r.name }.first_duplicate)
         | 
| 95 | 
            +
                  raise Error, "more than one release named #{dup.inspect}"
         | 
| 96 | 
            +
                end
         | 
| 97 | 
            +
              end
         | 
| 98 | 
            +
            end
         | 
| 99 | 
            +
             | 
| 100 | 
            +
            class Issue < ModelObject
         | 
| 101 | 
            +
              class Error < StandardError; end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
              field :title
         | 
| 104 | 
            +
              field :desc, :prompt => "Description", :multiline => true
         | 
| 105 | 
            +
              field :type, :generator => :get_type
         | 
| 106 | 
            +
              field :component, :generator => :get_component
         | 
| 107 | 
            +
              field :release, :generator => :get_release
         | 
| 108 | 
            +
              field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
         | 
| 109 | 
            +
              field :status, :ask => false, :default => :unstarted
         | 
| 110 | 
            +
              field :disposition, :ask => false
         | 
| 111 | 
            +
              field :creation_time, :ask => false, :generator => lambda { Time.now }
         | 
| 112 | 
            +
              field :references, :ask => false, :multi => true
         | 
| 113 | 
            +
              field :id, :ask => false, :generator => :make_id
         | 
| 114 | 
            +
              changes_are_logged
         | 
| 115 | 
            +
             | 
| 116 | 
            +
              attr_accessor :name
         | 
| 117 | 
            +
             | 
| 118 | 
            +
              STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
         | 
| 119 | 
            +
              STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
         | 
| 120 | 
            +
              DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
         | 
| 121 | 
            +
              TYPES = [ :bugfix, :feature ]
         | 
| 122 | 
            +
              STATUSES = STATUS_WIDGET.keys
         | 
| 123 | 
            +
             | 
| 124 | 
            +
              STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
         | 
| 125 | 
            +
              DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
         | 
| 126 | 
            +
             | 
| 127 | 
            +
              def before_serialize project
         | 
| 128 | 
            +
                self.desc = project.issues.inject(desc) do |s, i|
         | 
| 129 | 
            +
                  s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
         | 
| 130 | 
            +
                end
         | 
| 131 | 
            +
              end
         | 
| 132 | 
            +
             | 
| 133 | 
            +
              def interpolated_desc issues
         | 
| 134 | 
            +
                issues.inject(desc) do |s, i|
         | 
| 135 | 
            +
                  s.gsub(/\{issue #{i.id}\}/, block_given? ? yield(i) : i.name)
         | 
| 136 | 
            +
                end.gsub(/\{issue \w+\}/, "[unknown issue]")
         | 
| 137 | 
            +
              end
         | 
| 138 | 
            +
             | 
| 139 | 
            +
              ## make a unique id
         | 
| 140 | 
            +
              def make_id config, project
         | 
| 141 | 
            +
                SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
         | 
| 142 | 
            +
              end
         | 
| 143 | 
            +
             | 
| 144 | 
            +
              def sort_order; [STATUS_SORT_ORDER[@status], creation_time] end
         | 
| 145 | 
            +
              def status_widget; STATUS_WIDGET[@status] end
         | 
| 146 | 
            +
             | 
| 147 | 
            +
              def status_string; STATUS_STRINGS[status] || status.to_s end
         | 
| 148 | 
            +
              def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
         | 
| 149 | 
            +
             | 
| 150 | 
            +
              def closed?; status == :closed end
         | 
| 151 | 
            +
              def open?; !closed? end
         | 
| 152 | 
            +
              def in_progress?; status == :in_progress end
         | 
| 153 | 
            +
              def bug?; type == :bugfix end
         | 
| 154 | 
            +
              def feature?; type == :feature end
         | 
| 155 | 
            +
             | 
| 156 | 
            +
              def start_work who, comment; change_status :in_progress, who, comment end
         | 
| 157 | 
            +
              def stop_work who, comment
         | 
| 158 | 
            +
                raise Error, "unstarted" unless self.status == :in_progress
         | 
| 159 | 
            +
                change_status :paused, who, comment
         | 
| 160 | 
            +
              end
         | 
| 161 | 
            +
             | 
| 162 | 
            +
              def close disp, who, comment
         | 
| 163 | 
            +
                raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
         | 
| 164 | 
            +
                log "closed issue with disposition #{disp}", who, comment
         | 
| 165 | 
            +
                self.status = :closed
         | 
| 166 | 
            +
                self.disposition = disp
         | 
| 167 | 
            +
              end
         | 
| 168 | 
            +
             | 
| 169 | 
            +
              def change_status to, who, comment
         | 
| 170 | 
            +
                raise Error, "unknown status #{to}" unless STATUSES.member? to
         | 
| 171 | 
            +
                raise Error, "already marked as #{to}" if status == to
         | 
| 172 | 
            +
                log "changed status from #{@status} to #{to}", who, comment
         | 
| 173 | 
            +
                self.status = to
         | 
| 174 | 
            +
              end
         | 
| 175 | 
            +
              private :change_status
         | 
| 176 | 
            +
             | 
| 177 | 
            +
              def change hash, who, comment
         | 
| 178 | 
            +
                what = []
         | 
| 179 | 
            +
                if title != hash[:title]
         | 
| 180 | 
            +
                  what << "changed title"
         | 
| 181 | 
            +
                  self.title = hash[:title]
         | 
| 182 | 
            +
                end
         | 
| 183 | 
            +
             | 
| 184 | 
            +
                if desc != hash[:description]
         | 
| 185 | 
            +
                  what << "changed description"
         | 
| 186 | 
            +
                  self.desc = hash[:description]
         | 
| 187 | 
            +
                end
         | 
| 188 | 
            +
             | 
| 189 | 
            +
                if reporter != hash[:reporter]
         | 
| 190 | 
            +
                  what << "changed reporter"
         | 
| 191 | 
            +
                  self.reporter = hash[:reporter]
         | 
| 192 | 
            +
                end
         | 
| 193 | 
            +
             | 
| 194 | 
            +
                unless what.empty?
         | 
| 195 | 
            +
                  log what.join(", "), who, comment
         | 
| 196 | 
            +
                  true
         | 
| 197 | 
            +
                end
         | 
| 198 | 
            +
              end
         | 
| 199 | 
            +
             | 
| 200 | 
            +
              def assign_to_release release, who, comment
         | 
| 201 | 
            +
                log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
         | 
| 202 | 
            +
                self.release = release.name
         | 
| 203 | 
            +
              end
         | 
| 204 | 
            +
             | 
| 205 | 
            +
              def unassign who, comment
         | 
| 206 | 
            +
                raise Error, "not assigned to a release" unless release
         | 
| 207 | 
            +
                log "unassigned from release #{release}", who, comment
         | 
| 208 | 
            +
                self.release = nil
         | 
| 209 | 
            +
              end
         | 
| 210 | 
            +
             | 
| 211 | 
            +
              def get_type config, project
         | 
| 212 | 
            +
                ask "Is this a (b)ugfix or a (f)eature?", :restrict => /^[bf]$/
         | 
| 213 | 
            +
                type == "b" ? :bugfix : :feature
         | 
| 214 | 
            +
              end
         | 
| 215 | 
            +
             | 
| 216 | 
            +
              def get_component config, project
         | 
| 217 | 
            +
                if project.components.size == 1
         | 
| 218 | 
            +
                  project.components.first
         | 
| 219 | 
            +
                else
         | 
| 220 | 
            +
                  ask_for_selection project.components, "component", :name
         | 
| 221 | 
            +
                end.name
         | 
| 222 | 
            +
              end
         | 
| 223 | 
            +
             | 
| 224 | 
            +
              def get_release config, project
         | 
| 225 | 
            +
                releases = project.releases.select { |r| r.unreleased? }
         | 
| 226 | 
            +
                if !releases.empty? && ask_yon("Assign to a release now?")
         | 
| 227 | 
            +
                  if releases.size == 1
         | 
| 228 | 
            +
                    r = releases.first
         | 
| 229 | 
            +
                    puts "Assigning to release #{r.name}."
         | 
| 230 | 
            +
                    r
         | 
| 231 | 
            +
                  else
         | 
| 232 | 
            +
                    ask_for_selection releases, "release", :name
         | 
| 233 | 
            +
                  end.name
         | 
| 234 | 
            +
                end
         | 
| 235 | 
            +
              end
         | 
| 236 | 
            +
             | 
| 237 | 
            +
              def get_reporter config, project
         | 
| 238 | 
            +
                reporter = ask "Creator", :default => config.user
         | 
| 239 | 
            +
              end
         | 
| 240 | 
            +
            end
         | 
| 241 | 
            +
             | 
| 242 | 
            +
            class Config < ModelObject
         | 
| 243 | 
            +
              field :name, :prompt => "Your name", :default_generator => :get_default_name
         | 
| 244 | 
            +
              field :email, :prompt => "Your email address", :default_generator => :get_default_email
         | 
| 245 | 
            +
             | 
| 246 | 
            +
              def user; "#{name} <#{email}>" end
         | 
| 247 | 
            +
             | 
| 248 | 
            +
              def get_default_name
         | 
| 249 | 
            +
                require 'etc'
         | 
| 250 | 
            +
             | 
| 251 | 
            +
                name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first
         | 
| 252 | 
            +
              end
         | 
| 253 | 
            +
             | 
| 254 | 
            +
              def get_default_email
         | 
| 255 | 
            +
                require 'socket'
         | 
| 256 | 
            +
                email = ENV["USER"] + "@" + 
         | 
| 257 | 
            +
                  begin
         | 
| 258 | 
            +
                    Socket.gethostbyname(Socket.gethostname).first
         | 
| 259 | 
            +
                  rescue SocketError
         | 
| 260 | 
            +
                    Socket.gethostname
         | 
| 261 | 
            +
                  end
         | 
| 262 | 
            +
              end
         | 
| 263 | 
            +
            end
         | 
| 264 | 
            +
             | 
| 265 | 
            +
            end
         | 
    
        data/lib/model.rb
    ADDED
    
    | @@ -0,0 +1,137 @@ | |
| 1 | 
            +
            require 'yaml'
         | 
| 2 | 
            +
            require "lowline"; include Lowline
         | 
| 3 | 
            +
            require "util"
         | 
| 4 | 
            +
            require 'sha1'
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Ditz
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            class ModelObject
         | 
| 9 | 
            +
              class ModelError < StandardError; end
         | 
| 10 | 
            +
             | 
| 11 | 
            +
              ## yamlability
         | 
| 12 | 
            +
              def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
         | 
| 13 | 
            +
              def self.yaml_other_thing; name.split('::').last.dcfirst end
         | 
| 14 | 
            +
              def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
         | 
| 15 | 
            +
              def to_yaml_properties; self.class.fields.map { |f| "@#{f.to_s}" } end
         | 
| 16 | 
            +
              def self.inherited subclass
         | 
| 17 | 
            +
                YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
         | 
| 18 | 
            +
                  YAML.object_maker(subclass, val)
         | 
| 19 | 
            +
                end
         | 
| 20 | 
            +
              end
         | 
| 21 | 
            +
              def before_serialize(*a); end
         | 
| 22 | 
            +
              def after_deserialize(*a); end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
              def self.field name, opts={}
         | 
| 25 | 
            +
                @fields ||= [] # can't use a hash because want to preserve field order when serialized
         | 
| 26 | 
            +
                raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
         | 
| 27 | 
            +
                @fields << [name, opts]
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                attr_reader name
         | 
| 30 | 
            +
                if opts[:multi]
         | 
| 31 | 
            +
                  single_name = name.to_s.sub(/s$/, "") # oh yeah
         | 
| 32 | 
            +
                  define_method "add_#{single_name}" do |obj|
         | 
| 33 | 
            +
                    array = self.instance_variable_get("@#{name}")
         | 
| 34 | 
            +
                    raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
         | 
| 35 | 
            +
                    changed!
         | 
| 36 | 
            +
                    array << obj
         | 
| 37 | 
            +
                  end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                  define_method "drop_#{single_name}" do |obj|
         | 
| 40 | 
            +
                    return unless self.instance_variable_get("@#{name}").delete obj
         | 
| 41 | 
            +
                    changed!
         | 
| 42 | 
            +
                    obj
         | 
| 43 | 
            +
                  end
         | 
| 44 | 
            +
                end
         | 
| 45 | 
            +
                define_method "#{name}=" do |o|
         | 
| 46 | 
            +
                  changed!
         | 
| 47 | 
            +
                  instance_variable_set "@#{name}", o
         | 
| 48 | 
            +
                end
         | 
| 49 | 
            +
              end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
              def self.fields; @fields.map { |name, opts| name } end
         | 
| 52 | 
            +
             | 
| 53 | 
            +
              def self.changes_are_logged
         | 
| 54 | 
            +
                define_method(:changes_are_logged?) { true }
         | 
| 55 | 
            +
                field :log_events, :multi => true, :ask => false
         | 
| 56 | 
            +
              end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
              def self.from fn
         | 
| 59 | 
            +
                returning YAML::load_file(fn) do |o|
         | 
| 60 | 
            +
                  raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
         | 
| 61 | 
            +
                end
         | 
| 62 | 
            +
              end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
              ## depth-first search on all reachable ModelObjects. fuck yeah.
         | 
| 65 | 
            +
              def each_modelobject
         | 
| 66 | 
            +
                seen = {}
         | 
| 67 | 
            +
                to_see = [self]
         | 
| 68 | 
            +
                until to_see.empty?
         | 
| 69 | 
            +
                  cur = to_see.pop
         | 
| 70 | 
            +
                  seen[cur] = true
         | 
| 71 | 
            +
                  yield cur
         | 
| 72 | 
            +
                  cur.class.fields.each do |f|
         | 
| 73 | 
            +
                    val = cur.send(f)
         | 
| 74 | 
            +
                    next if seen[val]
         | 
| 75 | 
            +
                    if val.is_a?(ModelObject)
         | 
| 76 | 
            +
                      to_see.push val
         | 
| 77 | 
            +
                    elsif val.is_a?(Array)
         | 
| 78 | 
            +
                      to_see += val.select { |v| v.is_a?(ModelObject) }
         | 
| 79 | 
            +
                    end
         | 
| 80 | 
            +
                  end
         | 
| 81 | 
            +
                end
         | 
| 82 | 
            +
              end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
              def save! fn
         | 
| 85 | 
            +
                Ditz::debug "saving configuration to #{fn}"
         | 
| 86 | 
            +
                FileUtils.mv fn, "#{fn}~", :force => true rescue nil
         | 
| 87 | 
            +
                File.open(fn, "w") { |f| f.puts to_yaml }
         | 
| 88 | 
            +
              end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
              def log what, who, comment
         | 
| 91 | 
            +
                add_log_event([Time.now, who, what, comment])
         | 
| 92 | 
            +
                self
         | 
| 93 | 
            +
              end
         | 
| 94 | 
            +
             | 
| 95 | 
            +
              def initialize
         | 
| 96 | 
            +
                @changed = false
         | 
| 97 | 
            +
                @log_events = []
         | 
| 98 | 
            +
              end
         | 
| 99 | 
            +
             | 
| 100 | 
            +
              def changed?; @changed end
         | 
| 101 | 
            +
              def changed!; @changed = true end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
              def self.create_interactively opts={}
         | 
| 104 | 
            +
                o = self.new
         | 
| 105 | 
            +
                args = opts[:args] || []
         | 
| 106 | 
            +
                @fields.each do |name, field_opts|
         | 
| 107 | 
            +
                  val = if opts[:with] && opts[:with][name]
         | 
| 108 | 
            +
                    opts[:with][name]
         | 
| 109 | 
            +
                  elsif field_opts[:generator].is_a? Proc
         | 
| 110 | 
            +
                    field_opts[:generator].call(*args)
         | 
| 111 | 
            +
                  elsif field_opts[:generator]
         | 
| 112 | 
            +
                    o.send field_opts[:generator], *args
         | 
| 113 | 
            +
                  elsif field_opts[:ask] == false # nil counts as true here
         | 
| 114 | 
            +
                    field_opts[:default] || (field_opts[:multi] ? [] : nil)
         | 
| 115 | 
            +
                  else
         | 
| 116 | 
            +
                    q = field_opts[:prompt] || name.to_s.ucfirst
         | 
| 117 | 
            +
                    if field_opts[:multiline]
         | 
| 118 | 
            +
                      ask_multiline q
         | 
| 119 | 
            +
                    else
         | 
| 120 | 
            +
                      default = if field_opts[:default_generator].is_a? Proc
         | 
| 121 | 
            +
                        field_opts[:default_generator].call(*args)
         | 
| 122 | 
            +
                      elsif field_opts[:default_generator]
         | 
| 123 | 
            +
                        o.send field_opts[:default_generator], *args
         | 
| 124 | 
            +
                      elsif field_opts[:default]
         | 
| 125 | 
            +
                        field_opts[:default]
         | 
| 126 | 
            +
                      end
         | 
| 127 | 
            +
                        
         | 
| 128 | 
            +
                      ask q, :default => default
         | 
| 129 | 
            +
                    end
         | 
| 130 | 
            +
                  end
         | 
| 131 | 
            +
                  o.send("#{name}=", val)
         | 
| 132 | 
            +
                end
         | 
| 133 | 
            +
                o
         | 
| 134 | 
            +
              end
         | 
| 135 | 
            +
            end
         | 
| 136 | 
            +
             | 
| 137 | 
            +
            end
         |