Project

General

Profile

Feature #1518 » logtime_changeratio_via_commit-message-r1926.diff

Rebased patch against r1926. - Mischa The Evil, 2008-10-06 02:12

View differences:

app/models/changeset.rb (working copy)
40 40
  validates_uniqueness_of :revision, :scope => :repository_id
41 41
  validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
42 42
  
43
  after_create :parse_comment
44
  
43 45
  def revision=(r)
44 46
    write_attribute :revision, (r.nil? ? nil : r.to_s)
45 47
  end
......
57 59
    repository.project
58 60
  end
59 61
  
60
  def after_create
61
    scan_comment_for_issue_ids
62
  # This starts the comment parsing. Executed by an after_save filter
63
  def parse_comment
64
    return if comments.blank?
65

  
66
    keywords = (ref_keywords + fix_keywords)
67
    return if keywords.blank?
68

  
69
    process_issues_marked_by(keywords)
62 70
  end
63
  require 'pp'
64
  
65
  def scan_comment_for_issue_ids
66
    return if comments.blank?
67
    # keywords used to reference issues
68
    ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
69
    # keywords used to fix issues
70
    fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
71
    # status and optional done ratio applied
72
    fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
73
    done_ratio = Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
74
    
75
    kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
76
    return if kw_regexp.blank?
77
    
71

  
72
  # Returns the previous changeset
73
  def previous
74
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
75
  end
76

  
77
  # Returns the next changeset
78
  def next
79
    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
80
  end
81

  
82
 protected
83

  
84
  # This parses the whole comment. Therefore the comment gets split into parts.
85
  def process_issues_marked_by(ticket_keywords)
78 86
    referenced_issues = []
79
    
80
    if ref_keywords.delete('*')
81
      # find any issue ID in the comments
82
      target_issue_ids = []
83
      comments.scan(%r{([\s\(,-]|^)#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] }
84
      referenced_issues += repository.project.issues.find_all_by_id(target_issue_ids)
85
    end
86
    
87
    comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match|
87
    comments.scan( splitting_regexp(ticket_keywords) ).each do |match|
88 88
      action = match[0]
89 89
      target_issue_ids = match[1].scan(/\d+/)
90
      rest = match.last
91
      
90 92
      target_issues = repository.project.issues.find_all_by_id(target_issue_ids)
91
      if fix_status && fix_keywords.include?(action.downcase)
93
      process_part(action, target_issues, rest)
94

  
95
      referenced_issues += target_issues
96
    end
97

  
98
    self.issues = referenced_issues.uniq
99
  end
100

  
101
  # returns a regexp that splits the long comment into parts
102
  #
103
  # Each part starts with a valid ticket reference and 
104
  # either ends with one or ends at the end of the comment
105
  def splitting_regexp(ticket_keywords)
106
    ref_any = ticket_keywords.delete('*')
107
    joined_kw = ticket_keywords.join("|")
108
    first = "(#{joined_kw})#{ref_any ? '*' : '+' }"
109
    second = joined_kw + (ref_any ? '|#' : '')
110
    /#{first}[\s:]*(([\s,;&]*#?\d+)+)(.*?)(?=#{second}|\Z)/im 
111
  end
112

  
113
  # Process_part analyses the part and executes ticket changes, time logs etc.
114
  def process_part(action,target_issues,rest)
115
    if Setting.advanced_commit_parsing?
116
      time = extract_time!(rest)
117
      ratio = extract_ratio!(rest)
118
    end
119

  
120
    target_issues.each do |issue|
121
      journal = init_journal(issue)
122
      if fix_status && action && fix_keywords.include?(action.downcase)
92 123
        # update status of issues
93 124
        logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
94
        target_issues.each do |issue|
95
          # the issue may have been updated by the closure of another one (eg. duplicate)
96
          issue.reload
97
          # don't change the status is the issue is closed
98
          next if issue.status.is_closed?
99
          user = committer_user || User.anonymous
100
          csettext = "r#{self.revision}"
101
          if self.scmid && (! (csettext =~ /^r[0-9]+$/))
102
            csettext = "commit:\"#{self.scmid}\""
103
          end
104
          journal = issue.init_journal(user, l(:text_status_changed_by_changeset, csettext))
105
          issue.status = fix_status
106
          issue.done_ratio = done_ratio if done_ratio
107
          issue.save
108
          Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
125
        # the issue may have been updated by the closure of another one (eg. duplicate)
126
        issue.reload
127
        # don't change the status is the issue is closed
128
        break if issue.status.is_closed?
129
        issue.status = fix_status
130
        issue.done_ratio = done_ratio if done_ratio
131
      else
132
        if ratio and !issue.status.is_closed?
133
          issue.done_ratio = ratio
109 134
        end
110 135
      end
111
      referenced_issues += target_issues
136
      if time && issue.time_entries.find(:first, :conditions => ['spent_on = ? AND comments = ? AND user_id = ?',committed_on.to_date,rest[0..254],committer_user.id]).nil?
137
        time_entry = TimeEntry.new( :hours => time,
138
                                    :spent_on => committed_on.to_date,
139
                                    :activity_id => activity_id,
140
                                    :comments => rest[0..254],
141
                                    :user => committer_user)
142
        time_entry.hours /= target_issues.length
143
        issue.time_entries << time_entry
144
      end
145
      if issue.changed?
146
        issue.save
147
        Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
148
      end
112 149
    end
113
    
114
    self.issues = referenced_issues.uniq
115 150
  end
116 151

  
152
  # init the journal for our issue
153
  def init_journal(issue)
154
    csettext = "r#{self.revision}"
155
    if self.scmid && (! (csettext =~ /^r[0-9]+$/))
156
      csettext = "commit:\"#{self.scmid}\""
157
    end
158
    issue.init_journal(committer_user, l(:text_status_changed_by_changeset, csettext))
159
  end
160

  
161
  # extracts the time
162
  def extract_time!(string)
163
    extract!(/(?:#{time_keywords.join("|")})[\s:]+(\d+[.,:hm ]*\d*[m ]*)/,string)
164
  end
165

  
166
  # extracts the ratio
167
  def extract_ratio!(string)
168
    extract!(/(?:#{ratio_keywords.join("|")})[\s:]+(\d+)%?/,string)
169
  end
170

  
171
  # generic extract function. Notice the !. The original string is silently manipulated
172
  def extract!(regexp,string)
173
    if match = string.match(/(.*?)#{regexp}(.*)/mi)
174
      replacement = if match[1] && !match[1].strip.empty?
175
                      match[1].strip + ' ' + match[3].strip
176
                    else
177
                      match[3].strip
178
                    end
179
      string.replace(replacement)
180
      match[2]
181
    end
182
  end
183

  
184
  # keywords used to reference issues
185
  def ref_keywords
186
    @ref_keywords ||= Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
187
  end
188

  
189
  # keywords used to fix issues
190
  def fix_keywords
191
    @fix_keywords ||= Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
192
  end
193

  
194
  # keywords used to set the ratio of the issues
195
  def ratio_keywords
196
    @ratio_keywords ||= Setting.commit_ratio_keywords.downcase.split(',').collect(&:strip)
197
  end
198

  
199
  # keywords used to log time of an issue
200
  def time_keywords
201
    @time_keywords ||= Setting.commit_time_keywords.downcase.split(',').collect(&:strip)
202
  end
203

  
204
  # status if an issue is fixed
205
  def fix_status
206
    @fix_status ||= IssueStatus.find_by_id(Setting.commit_fix_status_id)
207
  end
208

  
209
  # the ratio if an issue is fixed
210
  def done_ratio
211
    @done_ratio ||= Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
212
  end
213

  
214
  # gets the default activity id or the id of the first
215
  def activity_id
216
    @activity_id ||= (Enumeration.default('ACTI') || Enumeration::get_values('ACTI').first).id
217
  end
218

  
117 219
  # Returns the Redmine User corresponding to the committer
220
  # or the anonymous user
118 221
  def committer_user
119
    if committer && committer.strip =~ /^([^<]+)(<(.*)>)?$/
222
    @user ||= if committer && committer.strip =~ /^([^<]+)(<(.*)>)?$/
120 223
      username, email = $1.strip, $3
121 224
      u = User.find_by_login(username)
122 225
      u ||= User.find_by_mail(email) unless email.blank?
123 226
      u
124
    end
227
    end || User.anonymous
125 228
  end
126 229
  
127
  # Returns the previous changeset
128
  def previous
129
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
130
  end
131

  
132
  # Returns the next changeset
133
  def next
134
    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
135
  end
136
  
137 230
  # Strips and reencodes a commit log before insertion into the database
138 231
  def self.normalize_comments(str)
139 232
    to_utf8(str.to_s.strip)
app/models/repository.rb (working copy)
92 92
    @latest_changeset ||= changesets.find(:first)
93 93
  end
94 94
    
95
  def scan_changesets_for_issue_ids
96
    self.changesets.each(&:scan_comment_for_issue_ids)
97
  end
98
  
99 95
  # fetch new changesets for all repositories
100 96
  # can be called periodically by an external script
101 97
  # eg. ruby script/runner "Repository.fetch_changesets"
......
103 99
    find(:all).each(&:fetch_changesets)
104 100
  end
105 101
  
106
  # scan changeset comments to find related and fixed issues for all repositories
107
  def self.scan_changesets_for_issue_ids
108
    find(:all).each(&:scan_changesets_for_issue_ids)
109
  end
110

  
111 102
  def self.scm_name
112 103
    'Abstract'
113 104
  end
app/views/settings/_repositories.rhtml (working copy)
32 32
<br /><em><%= l(:text_comma_separated) %></em></p>
33 33
</fieldset>
34 34

  
35
<fieldset class="box tabular settings"><legend><%= l(:text_issues_advanced_commit_message_keywords) %></legend>
36
<p><label><%= l(:setting_advanced_commit_keywords) %></label>
37
<%= check_box_tag 'settings[advanced_commit_parsing]', 1, Setting.advanced_commit_parsing?, :onclick=>"Element.toggle('advanced_keywords'); return true;" %><%= hidden_field_tag 'settings[advanced_commit_parsing]', 0 %></p>
38

  
39
<div id="advanced_keywords" <%= Setting.advanced_commit_parsing? ? '' : 'style="display:none"' %>>
40
<p><label><%= l(:setting_commit_time_keywords) %></label>
41
<%= text_field_tag 'settings[commit_time_keywords]', Setting.commit_time_keywords, :size => 30 %>
42
<br /><em><%= l(:text_comma_separated) %></em></p>
43
<p><label><%= l(:setting_commit_ratio_keywords) %></label>
44
<%= text_field_tag 'settings[commit_ratio_keywords]', Setting.commit_ratio_keywords, :size => 30 %>
45
<br /><em><%= l(:text_comma_separated) %></em></p>
46
</div>
47
</fieldset>
48

  
35 49
<%= submit_tag l(:button_save) %>
36 50
<% end %>
config/settings.yml (working copy)
81 81
  default: 0
82 82
commit_fix_done_ratio:
83 83
  default: 100
84
advanced_commit_parsing:
85
  default: 0
86
commit_time_keywords:
87
  default: 'time,log'
88
commit_ratio_keywords:
89
  default: 'done,ratio'
84 90
# autologin duration in days
85 91
# 0 means autologin is disabled 
86 92
autologin:
lang/en.yml (working copy)
641 641
enumeration_issue_priorities: Issue priorities
642 642
enumeration_doc_categories: Document categories
643 643
enumeration_activities: Activities (time tracking)
644

  
645
setting_advanced_commit_keywords: Enable advanced keywords
646
setting_commit_time_keywords: Time logging keywords
647
setting_commit_ratio_keywords: Done ratio keywords
648
text_issues_advanced_commit_message_keywords: Logging time and setting issue ratios via commit messages
test/unit/changeset_test.rb (working copy)
32 32
    c = Changeset.new(:repository => Project.find(1).repository,
33 33
                      :committed_on => Time.now,
34 34
                      :comments => 'New commit (#2). Fixes #1')
35
    c.scan_comment_for_issue_ids
36
    
35

  
36
    c.parse_comment
37

  
37 38
    assert_equal [1, 2], c.issue_ids.sort
38 39
    fixed = Issue.find(1)
39 40
    assert fixed.closed?
40 41
    assert_equal 90, fixed.done_ratio
41 42
  end
43

  
44
  def test_not_associate_any_mentioned_tickets
45
    Setting.commit_ref_keywords = 'key'
46
    
47
    c = Changeset.new(:repository => Project.find(1).repository,
48
                      :committed_on => Time.now,
49
                      :comments => 'New commit (#2). #1')
50

  
51
    c.parse_comment
52

  
53
    assert_equal [], c.issue_ids
54
  end
42 55
  
56
  def test_fixes_multiple_tickets
57
    Setting.commit_fix_status_id = IssueStatus.find(:first, :conditions => ["is_closed = ?", true]).id
58
    Setting.commit_fix_keywords = 'fixes , closes'
59
    
60
    c = Changeset.new(:repository => Project.find(1).repository,
61
                      :committed_on => Time.now,
62
                      :comments => 'Fixes #1,#2')
63

  
64
    c.parse_comment
65

  
66
    assert_equal [1, 2], c.issue_ids.sort
67
  end
68
  
43 69
  def test_ref_keywords_any_line_start
44 70
    Setting.commit_ref_keywords = '*'
45 71

  
46 72
    c = Changeset.new(:repository => Project.find(1).repository,
47 73
                      :committed_on => Time.now,
48 74
                      :comments => '#1 is the reason of this commit')
49
    c.scan_comment_for_issue_ids
75
    c.parse_comment
50 76

  
51 77
    assert_equal [1], c.issue_ids.sort
52 78
  end
53 79

  
80
  def test_log_time_without_issues_should_do_nothing
81
    count = TimeEntry.count
82
    Setting.advanced_commit_parsing = 1
83
    Setting.commit_ref_keywords = '*'
84

  
85
    c = Changeset.new(:repository => Project.find(1).repository,
86
                      :committed_on => Time.now,
87
                      :comments => 'time 3,5')
88
    c.parse_comment
89

  
90
    assert_equal count, TimeEntry.count
91
  end
92

  
93
  def test_log_time_should_work
94
    Setting.advanced_commit_parsing = 1
95
    Setting.commit_ref_keywords = '*'
96
    
97
    comment = <<-EOL
98
#1
99
time 3,5
100
EOL
101
    
102
    time = Time.now
103
    c = Changeset.new(:repository => Project.find(1).repository,
104
                      :committed_on => time,
105
                      :comments => comment)
106

  
107
    c.parse_comment
108

  
109
    time_entry = TimeEntry.last
110
    assert_equal 3.5, time_entry.hours
111
  end
112

  
113
  def test_log_time_should_not_create_duplicate_logs
114
    count = TimeEntry.count
115
    Setting.advanced_commit_parsing = 1
116
    Setting.commit_ref_keywords = '*'
117

  
118
    committed_on = Time.now
119
    
120
    comment = <<-EOL
121
#1
122
time 3,5
123
EOL
124
    
125
    c = Changeset.new(:repository => Project.find(1).repository,
126
                      :committed_on => committed_on,
127
                      :comments => comment)
128

  
129
    c.parse_comment
130
    c.parse_comment
131

  
132
    assert_equal count+1, TimeEntry.count
133
  end
134

  
135
  def test_log_time_splits_the_time_equally
136
    Setting.commit_fix_keywords = 'fixes , closes'
137
    Setting.advanced_commit_parsing = 1
138

  
139
    comment = <<-EOL
140
fixes #1,#2
141
time 3
142

  
143
the comment
144
EOL
145
    
146
    c = Changeset.new(:repository => Project.find(1).repository,
147
                      :committed_on => Time.now,
148
                      :comments => comment)
149

  
150
    c.parse_comment
151
    
152

  
153
    time_entry = TimeEntry.find(:first, :order => 'id DESC')
154
    assert_equal 1.5, time_entry.hours
155
    assert_equal 'the comment', time_entry.comments
156

  
157
  end
158

  
159
  def test_extract_time
160
    c = Changeset.new
161
    time_formats =  [ "2", "21.1", "2,1","7:12", "10h", "10 h", "45m", "45 m", "3h15", "3h 15", "3 h 15", "3 h 15m", "3 h 15 m"]
162
    
163
    time_formats.each do |format|
164
      assert_equal format, c.send(:extract_time!, "time #{format}")
165
    end
166
  end
167

  
168
  def test_set_done_ratio
169
    Setting.commit_ref_keywords = '*'
170
    Setting.advanced_commit_parsing = 1
171

  
172
    comment = <<-EOL
173
#1
174
done 50%
175
#2
176
done 40
177
EOL
178
    
179
    c = Changeset.new(:repository => Project.find(1).repository,
180
                      :committed_on => Time.now,
181
                      :comments => comment)
182

  
183
    c.parse_comment
184

  
185
    assert_equal [1, 2], c.issue_ids.sort
186
    assert_equal 50, Issue.find(1).done_ratio
187
    assert_equal 40, Issue.find(2).done_ratio
188
  end
189

  
190
  def test_set_committer_identified_by_email
191
    Setting.commit_fix_keywords = 'fixes'
192
    user = User.find(:first)
193
    
194
    c = Changeset.new(:repository => Project.find(1).repository,
195
                      :committed_on => Time.now,
196
                      :committer => "arnie<#{user.mail}>",
197
                      :comments => 'Fixes #1')
198

  
199
    c.parse_comment
200

  
201
    fixed = Issue.find(1)
202
    assert fixed.closed?
203
    assert_equal user, fixed.journals.find(:first, :order => 'id DESC').user
204
  end
205

  
206
  def test_set_committer_identified_by_login
207
    Setting.commit_fix_keywords = 'fixes'
208
    user = User.find(:first)
209
    
210
    c = Changeset.new(:repository => Project.find(1).repository,
211
                      :committed_on => Time.now,
212
                      :committer => user.login,
213
                      :comments => 'Fixes #1')
214

  
215
    c.parse_comment
216

  
217
    fixed = Issue.find(1)
218
    assert fixed.closed?
219
    assert_equal user, fixed.journals.find(:first, :order => 'id DESC').user
220
  end
221

  
222
  def test_set_annonymous_if_committer_unknown
223
    Setting.commit_fix_keywords = 'fixes'
224

  
225
    c = Changeset.new(:repository => Project.find(1).repository,
226
                      :committed_on => Time.now,
227
                      :committer => 'arnie',
228
                      :comments => 'Fixes #1')
229

  
230
    c.parse_comment
231

  
232
    fixed = Issue.find(1)
233
    assert fixed.closed?
234
    assert_equal User.anonymous, fixed.journals.find(:first, :order => 'id DESC').user
235
  end
236

  
237
  def test_mail_deliveries
238
    ActionMailer::Base.deliveries.clear
239

  
240
    Setting.commit_fix_keywords = 'fixes'
241
    
242
    c = Changeset.new(:repository => Project.find(1).repository,
243
                      :committed_on => Time.now,
244
                      :comments => 'Fixes #1')
245

  
246
    c.parse_comment
247
    
248
    assert_equal 1, ActionMailer::Base.deliveries.size
249
  end
250

  
251
  def test_ignore_cross_refrenced_issue_ids
252
    Setting.commit_fix_keywords = 'fixes'
253

  
254
    c = Changeset.new(:repository => Project.find(1).repository,
255
                      :committed_on => Time.now,
256
                      :comments => 'Fixes #1234')
257

  
258
    c.parse_comment
259

  
260
    assert_equal [], c.issue_ids.sort
261
  end
262

  
54 263
  def test_previous
55 264
    changeset = Changeset.find_by_revision('3')
56 265
    assert_equal Changeset.find_by_revision('2'), changeset.previous
......
70 279
    changeset = Changeset.find_by_revision('4')
71 280
    assert_nil changeset.next
72 281
  end
282

  
283
  def test_for_changeset_comments_strip
284
    comment = <<-COMMENT
285
    This is a loooooooooooooooooooooooooooong comment                                                   
286
                                                                                                       
287
                                                                                            
288
    COMMENT
289
    changeset = Changeset.new :comments => comment
290
    assert_equal( 'This is a loooooooooooooooooooooooooooong comment', changeset.comments )
291
  end
292
  
293

  
73 294
end
test/unit/repository_test.rb (working copy)
65 65
    Setting.delete_all
66 66
  end
67 67
  
68
  def test_scan_changesets_for_issue_ids
69
    # choosing a status to apply to fix issues
70
    Setting.commit_fix_status_id = IssueStatus.find(:first, :conditions => ["is_closed = ?", true]).id
71
    Setting.commit_fix_done_ratio = "90"
72
    Setting.commit_ref_keywords = 'refs , references, IssueID'
73
    Setting.commit_fix_keywords = 'fixes , closes'
74
    Setting.default_language = 'en'
75
    ActionMailer::Base.deliveries.clear
76
    
77
    # make sure issue 1 is not already closed
78
    fixed_issue = Issue.find(1)
79
    assert !fixed_issue.status.is_closed?
80
    old_status = fixed_issue.status
81
        
82
    Repository.scan_changesets_for_issue_ids
83
    assert_equal [101, 102], Issue.find(3).changeset_ids
84
    
85
    # fixed issues
86
    fixed_issue.reload
87
    assert fixed_issue.status.is_closed?
88
    assert_equal 90, fixed_issue.done_ratio
89
    assert_equal [101], fixed_issue.changeset_ids
90
    
91
    # issue change
92
    journal = fixed_issue.journals.find(:first, :order => 'created_on desc')
93
    assert_equal User.find_by_login('dlopper'), journal.user
94
    assert_equal 'Applied in changeset r2.', journal.notes
95
    
96
    # 2 email notifications
97
    assert_equal 2, ActionMailer::Base.deliveries.size
98
    mail = ActionMailer::Base.deliveries.first
99
    assert_kind_of TMail::Mail, mail
100
    assert mail.subject.starts_with?("[#{fixed_issue.project.name} - #{fixed_issue.tracker.name} ##{fixed_issue.id}]")
101
    assert mail.body.include?("Status changed from #{old_status} to #{fixed_issue.status}")
102
    
103
    # ignoring commits referencing an issue of another project
104
    assert_equal [], Issue.find(4).changesets
105
  end
106
  
107
  def test_for_changeset_comments_strip
108
    repository = Repository::Mercurial.create( :project => Project.find( 4 ), :url => '/foo/bar/baz' )
109
    comment = <<-COMMENT
110
    This is a loooooooooooooooooooooooooooong comment                                                   
111
                                                                                                       
112
                                                                                            
113
    COMMENT
114
    changeset = Changeset.new(
115
      :comments => comment, :commit_date => Time.now, :revision => 0, :scmid => 'f39b7922fb3c',
116
      :committer => 'foo <foo@example.com>', :committed_on => Time.now, :repository => repository )
117
    assert( changeset.save )
118
    assert_not_equal( comment, changeset.comments )
119
    assert_equal( 'This is a loooooooooooooooooooooooooooong comment', changeset.comments )
120
  end
121
  
122 68
  def test_for_urls_strip
123 69
    repository = Repository::Cvs.create(:project => Project.find(4), :url => ' :pserver:login:password@host:/path/to/the/repository',
124 70
                                                                     :root_url => 'foo  ')
(5-5/7)