patch_commit_messages3.diff

Jonas von Andrian, 2008-07-03 21:44

Download (22.6 KB)

View differences:

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
    count = TimeEntry.count
95
    Setting.advanced_commit_parsing = 1
96
    Setting.commit_ref_keywords = '*'
97
    
98
    comment = <<-EOL
99
#1
100
time 3,5
101
EOL
102
    
103
    c = Changeset.new(:repository => Project.find(1).repository,
104
                      :committed_on => Time.now,
105
                      :comments => comment)
106

  
107
    c.parse_comment
108

  
109
    assert_equal count+1, TimeEntry.count
110
  end
111

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

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

  
128
    c.parse_comment
129
    c.parse_comment
130

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

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

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

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

  
149
    c.parse_comment
150
    
151

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

  
156
  end
157

  
158
  def test_extract_time
159
    c = Changeset.new
160
    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"]
161
    
162
    time_formats.each do |format|
163
      assert_equal format, c.send(:extract_time!, "time #{format}")
164
    end
165
  end
166

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

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

  
182
    c.parse_comment
183

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

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

  
198
    c.parse_comment
199

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

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

  
214
    c.parse_comment
215

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

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

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

  
229
    c.parse_comment
230

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

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

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

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

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

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

  
257
    c.parse_comment
258

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

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

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

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

  
103 94
  def self.scm_name
104 95
    'Abstract'
105 96
  end
app/models/changeset.rb (working copy)
34 34
  validates_presence_of :repository_id, :revision, :committed_on, :commit_date
35 35
  validates_uniqueness_of :revision, :scope => :repository_id
36 36
  validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
37

  
38
  after_create :parse_comment
37 39
  
38 40
  def revision=(r)
39 41
    write_attribute :revision, (r.nil? ? nil : r.to_s)
......
52 54
    repository.project
53 55
  end
54 56
  
55
  def after_create
56
    scan_comment_for_issue_ids
57
  # This starts the comment parsing. Executed by an after_save filter
58
  def parse_comment
59
    return if comments.blank?
60

  
61
    keywords = (ref_keywords + fix_keywords)
62
    return if keywords.blank?
63

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

  
67
  # Returns the previous changeset
68
  def previous
69
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
70
  end
71

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

  
77
 protected
78

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

  
85 87
      target_issues = repository.project.issues.find_all_by_id(target_issue_ids)
86
      if fix_status && fix_keywords.include?(action.downcase)
88
      process_part(action, target_issues, rest)
89

  
90
      referenced_issues += target_issues
91
    end
92

  
93
    self.issues = referenced_issues.uniq
94
  end
95

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

  
108
  # Process_part analyses the part and executes ticket changes, time logs etc.
109
  def process_part(action,target_issues,rest)
110
    if Setting.advanced_commit_parsing?
111
      time = extract_time!(rest)
112
      ratio = extract_ratio!(rest)
113
    end
114

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

  
145
  # This updates the journal of an Issue and sends an update email if necessary
146
  def update_journal(issue)
147
    user = committer_user
148
    csettext = "r#{self.revision}"
149
    if self.scmid && (! (csettext =~ /^r[0-9]+$/))
150
      csettext = "commit:\"#{self.scmid}\""
151
    end
152
    journal = issue.init_journal(user, l(:text_status_changed_by_changeset, csettext))
153
    Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
154
  end
155

  
156
  # extracts the time
157
  def extract_time!(string)
158
    extract!(/(?:#{time_keywords.join("|")})[\s:]+(\d+[.,:hm ]*\d*[m ]*)/,string)
159
  end
160

  
161
  # extracts the ratio
162
  def extract_ratio!(string)
163
    extract!(/(?:#{ratio_keywords.join("|")})[\s:]+(\d+)%?/,string)
164
  end
165

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

  
179
  # keywords used to reference issues
180
  def ref_keywords
181
    @ref_keywords ||= Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
182
  end
183

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

  
189
  # keywords used to set the ratio of the issues
190
  def ratio_keywords
191
    @ratio_keywords ||= Setting.commit_ratio_keywords.downcase.split(',').collect(&:strip)
192
  end
193

  
194
  # keywords used to log time of an issue
195
  def time_keywords
196
    @time_keywords ||= Setting.commit_time_keywords.downcase.split(',').collect(&:strip)
197
  end
198

  
199
  # status if an issue is fixed
200
  def fix_status
201
    @fix_status ||= IssueStatus.find_by_id(Setting.commit_fix_status_id)
202
  end
203

  
204
  # the ratio if an issue is fixed
205
  def done_ratio
206
    @done_ratio ||= Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
207
  end
208

  
112 209
  # Returns the Redmine User corresponding to the committer
210
  # or the anonymous user
113 211
  def committer_user
114 212
    if committer && committer.strip =~ /^([^<]+)(<(.*)>)?$/
115 213
      username, email = $1.strip, $3
116 214
      u = User.find_by_login(username)
117 215
      u ||= User.find_by_mail(email) unless email.blank?
118 216
      u
119
    end
217
    end || User.anonymous
120 218
  end
121 219
  
122
  # Returns the previous changeset
123
  def previous
124
    @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
125
  end
126

  
127
  # Returns the next changeset
128
  def next
129
    @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
130
  end
131 220
end
app/views/settings/_repositories.rhtml (working copy)
29 29
<br /><em><%= l(:text_comma_separated) %></em></p>
30 30
</fieldset>
31 31

  
32
<fieldset class="box tabular settings"><legend><%= l(:text_issues_advanced_commit_message_keywords) %></legend>
33
<p><label><%= l(:setting_advanced_commit_keywords) %></label>
34
<%= 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>
35

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

  
32 46
<%= submit_tag l(:button_save) %>
33 47
<% end %>
lang/en.yml (working copy)
202 202
setting_sys_api_enabled: Enable WS for repository management
203 203
setting_commit_ref_keywords: Referencing keywords
204 204
setting_commit_fix_keywords: Fixing keywords
205
setting_advanced_commit_keywords: Enable advanced keywords
206
setting_commit_time_keywords: Time logging keywords
207
setting_commit_ratio_keywords: Done ratio keywords
205 208
setting_autologin: Autologin
206 209
setting_date_format: Date format
207 210
setting_time_format: Time format
......
584 587
text_unallowed_characters: Unallowed characters
585 588
text_comma_separated: Multiple values allowed (comma separated).
586 589
text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
590
text_issues_advanced_commit_message_keywords: Logging time and setting issue ratios via commit messages
587 591
text_issue_added: Issue %s has been reported by %s.
588 592
text_issue_updated: Issue %s has been updated by %s.
589 593
text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content ?
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: