Merge pull request #101 from apache/WHIMSY-334

don't process keystrokes if focus is in an INPUT or TEXTAREA
diff --git a/Rakefile b/Rakefile
index 90dddd5..c429932 100644
--- a/Rakefile
+++ b/Rakefile
@@ -111,10 +111,12 @@
   require 'whimsy/asf/config'
   require 'whimsy/asf/git'
   require 'whimsy/asf/svn'
+  require 'whimsy/lockfile'
 end
 
 namespace :svn do
-  task :update, [:listonly] => :config do |task, args|
+  task :update, [:arg1] => :config do |task, args|
+    arg1 = args.arg1 || '' # If defined, it is either the name of a checkout to update or 'skip'
     # Include all
     svnrepos = ASF::SVN.repo_entries(true) || {}
 
@@ -128,11 +130,14 @@
       mkdir_p? File.dirname(svn)
       Dir.chdir File.dirname(svn) do
         svnrepos.each do |name, description|
+          # skip the update unless it matches the parameter provided
+          # 'skip' is special and means update all list files
+          next unless name == arg1 || arg1 == 'skip' || arg1 == ''
           puts
           puts File.join(Dir.pwd, name)
           if description['list']
             puts "#{PREFIX} Updating listing file"
-            old,new = ASF::SVN.updatelisting(name)
+            old,new = ASF::SVN.updatelisting(name,nil,nil,description['dates'])
             if old == new
               puts "List is at revision #{old}."
             elsif old == nil
@@ -157,7 +162,7 @@
             end
           end
 
-          next if args.listonly == 'skip'
+          next if arg1 == 'skip'
           if noCheckout
             puts "Skipping" if depth == 'skip' # Must agree with monitors/svn.rb
             next
@@ -167,6 +172,8 @@
           if Dir.exist? name
             isSymlink = File.symlink?(name) # we don't want to change such checkouts
             Dir.chdir(name) {
+             # ensure single-threaded SVN updates
+             LockFile.lockfile(Dir.pwd, nil, File::LOCK_EX) do # ignore the return parameter
               system('svn', 'cleanup')
               unless isSymlink # Don't change depth for symlinks
                 curdepth = ASF::SVN.getInfoAsHash('.')['Depth'] || 'infinity' # not available as separate item
@@ -215,8 +222,10 @@
               end
 
               puts outerr # show what happened last
-            }
+             end # lockfile
+            } # chdir
           else # directory does not exist
+            # Don't bother locking here -- it should be very rarely needed
             system('svn', 'checkout', "--depth=#{depth}", svnpath, name)
              if files
                system('svn', 'update', *files, {chdir: name})
@@ -264,9 +273,7 @@
               errors += 1
             end
           else
-            require 'uri'
-            base = URI.parse('https://svn.apache.org/repos/')
-            puts "Directory not found - expecting checkout of #{(base + description['url']).to_s}"
+            puts "Directory not found - expecting checkout of #{ASF::SVN.svnpath!(name)}"
             errors += 1
           end
         end
diff --git a/config/setupmymac b/config/setupmymac
index f7a5928..2bab58c 100755
--- a/config/setupmymac
+++ b/config/setupmymac
@@ -285,6 +285,8 @@
 directories = [
   '/srv/agenda',
   '/srv/cache',
+  '/srv/mail',
+  '/srv/mail/secretary',
   '/srv/secretary',
   '/srv/secretary/tlpreq',
   '/srv/whimsy/www/logs',
diff --git a/lib/spec/lib/documents_spec.rb b/lib/spec/lib/documents_spec.rb
index 8688a10..95e4431 100644
--- a/lib/spec/lib/documents_spec.rb
+++ b/lib/spec/lib/documents_spec.rb
@@ -25,6 +25,17 @@
         expect(res[0]).to end_with('/emeritus1.txt')
         expect(res[1]).to eq('emeritus1.txt')
     end
+    it "findpath Person.find('emeritus1') should return same path as path!(file) " do
+        res = ASF::EmeritusFiles.findpath(ASF::Person.find('emeritus1'))
+        expect(res).to be_kind_of(Array)
+        expect(res.size).to eq(2)
+        svnpath = res[0]
+        file = res[1]
+        expect(svnpath).to end_with('/emeritus1.txt')
+        expect(file).to eq('emeritus1.txt')
+        path = ASF::EmeritusFiles.svnpath!(file)
+        expect(path).to eq(svnpath)
+    end
 end
 
 describe ASF::EmeritusReinstatedFiles do
@@ -83,4 +94,15 @@
         expect(res[0]).to end_with('/emeritus4.txt')
         expect(res[1]).to eq('emeritus4.txt')
     end
+    it "findpath Person.find('emeritus4') should return same path as path!(file) " do
+        res = ASF::EmeritusRescindedFiles.findpath(ASF::Person.find('emeritus4'))
+        expect(res).to be_kind_of(Array)
+        expect(res.size).to eq(2)
+        svnpath = res[0]
+        file = res[1]
+        expect(svnpath).to end_with('/emeritus4.txt')
+        expect(file).to eq('emeritus4.txt')
+        path = ASF::EmeritusRescindedFiles.svnpath!(file)
+        expect(path).to eq(svnpath)
+    end
 end
diff --git a/lib/spec/lib/svn_spec.rb b/lib/spec/lib/svn_spec.rb
index 42c08db..85211a9 100644
--- a/lib/spec/lib/svn_spec.rb
+++ b/lib/spec/lib/svn_spec.rb
@@ -391,4 +391,20 @@
     end
   end
 
-end
+  describe "ASF::SVN.getlisting" do
+    set_svnroot # need local test data here
+    it "getlisting('emeritus') returns array of 1" do
+      tag,list = ASF::SVN.getlisting('emeritus')
+      expect(list).to eq(['emeritus1.txt'])
+    end
+    it "getlisting('emeritus-requests-received') returns array of 1" do
+      tag,list = ASF::SVN.getlisting('emeritus-requests-received')
+      expect(list).to eq(['emeritus3.txt'])
+    end
+    it "getlisting('emeritus-requests-received,nil,true,true') returns array of [epoch,name]" do
+      tag,list = ASF::SVN.getlisting('emeritus-requests-received',nil,true,true)
+      expect(list).to eq([['1594814364','emeritus3.txt']])
+    end
+  end
+  
+  end
diff --git a/lib/test/svn/emeritus-requests-received.txt b/lib/test/svn/emeritus-requests-received.txt
index e9c6411..cd01b47 100644
--- a/lib/test/svn/emeritus-requests-received.txt
+++ b/lib/test/svn/emeritus-requests-received.txt
@@ -1,2 +1,2 @@
-0
-emeritus3.txt
+epoch:0
+1594814364:emeritus3.txt
diff --git a/lib/whimsy/asf/board.rb b/lib/whimsy/asf/board.rb
index d4bc5c7..90625a2 100644
--- a/lib/whimsy/asf/board.rb
+++ b/lib/whimsy/asf/board.rb
@@ -124,6 +124,7 @@
 
       def for(pmc)
         chair = pmc.chair
+        raise "no chair found for #{pmc.name}" if not chair
 
         if @directors.include? chair
           "#{chair.public_name}"
diff --git a/lib/whimsy/asf/documents.rb b/lib/whimsy/asf/documents.rb
index b128fe6..5836b6b 100644
--- a/lib/whimsy/asf/documents.rb
+++ b/lib/whimsy/asf/documents.rb
@@ -104,20 +104,23 @@
 
   class EmeritusFiles
     @base = 'emeritus'
-    def self.listnames
-      _, list = ASF::SVN.getlisting(@base)
+    def self.listnames(getDates=false)
+      _, list = ASF::SVN.getlisting(@base,nil,true,getDates)
       list
     end
 
     # Find the file name that matches a person
     # return nil if not exactly one match
     # TODO: should it raise an error on multiple matches?
-    def self.find(person)
+    def self.find(person, getDate=false)
       # TODO use common stem name method
       name = (person.attrs['cn'].first rescue person.member_name).force_encoding('utf-8').
         downcase.gsub(' ','-').gsub(/[^a-z0-9-]+/,'') rescue nil
       id = person.id
-      files = self.listnames.find_all do |file|
+      files = self.listnames(getDate).find_all do |file|
+        if file.is_a?(Array) # we have [epoch, file]
+          file = file[1]
+        end
         stem = file.split('.')[0] # directories don't have a trailing /
         stem == id or stem == name
       end
@@ -129,6 +132,11 @@
       end
     end
 
+    # return the svn path to an arbitrary file
+    def self.svnpath!(file)
+      ASF::SVN.svnpath!(@base, file)
+    end
+
     # Find the svnpath to the file for a person
     # Returns
     # svnpath, filename
@@ -138,7 +146,7 @@
       path = file = nil
       file = self.find(person)
       if file
-        path = ASF::SVN.svnpath!(@base, file)
+        path = self.svnpath!(file)
       end
       [path, file]
     end
diff --git a/lib/whimsy/asf/svn.rb b/lib/whimsy/asf/svn.rb
index ae2cc72..2edd787 100644
--- a/lib/whimsy/asf/svn.rb
+++ b/lib/whimsy/asf/svn.rb
@@ -21,7 +21,7 @@
     else
       svn_base = 'https://svn.apache.org/repos/'
     end
-    @base = URI.parse(svn_base)
+    @base = URI.parse(svn_base).untaint
     @mock = 'file:///var/tools/svnrep/'
     @semaphore = Mutex.new
     @testdata = {}
@@ -136,7 +136,7 @@
       unless url # bad entry
         raise Exception.new("Unable to find url attribute for SVN entry #{name}")
       end
-      return (@base+url).to_s
+      return (@base+url).to_s.untaint # to_s makes the var tainted
     end
 
     # fetch a repository URL by name - abort if not found
@@ -319,7 +319,8 @@
           end
           Wunderbar.error "command #{command.first.inspect} is invalid" unless command.first =~ %r{^[a-z]+$}
           command.drop(1).each do |cmd|
-            Wunderbar.error "Invalid option #{cmd.inspect}" unless cmd =~ %r{^(--[a-z][a-z=]+|-[a-z])$}
+            # Allow --option, -lnumber or -x
+            Wunderbar.error "Invalid option #{cmd.inspect}" unless cmd =~ %r{^(--[a-z][a-z=]+|-l\d+|-[a-z])$}
           end
         else
           raise ArgumentError.new "command must be a String or an Array of Strings"
@@ -817,7 +818,7 @@
     # The extra parameter is an array of commands
     # These must themselves be arrays to ensure correct processing of white-space
     # Parameters:
-    #   path - file path or SVN URL (http(s) or file:)
+    #   path - file path or SVN URL (http(s): or file: or svn:)
     #   message - commit message
     #   env - for username and password
     #   _ - Wunderbar context
@@ -885,7 +886,7 @@
         if options[:dryrun]
           puts cmds # TODO: not sure this is correct for Wunderbar
         else
-          rc = ASF::SVN.svnmucc_(cmds,msg,env,_,filerev,{tmpdir: tmpdir})
+          rc = ASF::SVN.svnmucc_(cmds,msg,env,_,filerev,{tmpdir: tmpdir, verbose: options[:verbose]})
           raise "svnmucc failure #{rc} committing" unless rc == 0
           rc
         end
@@ -894,11 +895,14 @@
       end
     end
     
+    EPOCH_SEP = ':' # separator
+    EPOCH_TAG = 'epoch'+EPOCH_SEP # marker in file to show epochs are present
+    EPOCH_LEN = EPOCH_TAG.size
     # update directory listing in /srv/svn/<name>.txt
     # N.B. The listing includes the trailing '/' so directory names can be distinguished
     # @return filerev, svnrev
     # on error return nil,message
-    def self.updatelisting(name, user=nil, password=nil)
+    def self.updatelisting(name, user=nil, password=nil, storedates=false)
       url = self.svnurl(name)
       unless url
         return nil,"Cannot find URL"
@@ -906,20 +910,42 @@
       listfile, listfiletmp = self.listingNames(name)
       filerev = "0"
       svnrev = "?"
+      filedates = false
       begin
         open(listfile) do |l|
           filerev = l.gets.chomp
+          if filerev.start_with? EPOCH_TAG # drop the marker
+            filerev = filerev[EPOCH_LEN..-1] 
+            filedates = true
+          end
         end
       rescue
       end
       svnrev, err = self.getInfoItem(url,'last-changed-revision',user,password)
       if svnrev
         begin
-          unless filerev == svnrev
-            list = self.list(url, user, password)
-            open(listfiletmp,'w') do |w|
-              w.puts svnrev
-              w.puts list
+          unless filerev == svnrev && filedates == storedates
+            list = self.list(url, user, password, storedates)
+            if storedates
+              require 'nokogiri'
+              require 'date'
+              open(listfiletmp,'w') do |w|
+                w.puts "#{EPOCH_TAG}#{svnrev}" # show that this file has epochs
+                xml_doc  = Nokogiri::XML(list)
+                xml_doc.css('entry').each do |entry|
+                  kind = entry.css('@kind').text
+                  name = entry.at_css('name').text
+                  date = entry.at_css('date').text
+                  epoch = DateTime.parse(date).strftime('%s')
+                  # The separator is the last character of the epoch tag
+                  w.puts "%s#{EPOCH_SEP}%s%s" % [epoch,name,kind=='dir' ? '/' : '']
+                end
+              end
+            else
+              open(listfiletmp,'w') do |w|
+                w.puts svnrev
+                w.puts list
+              end
             end
             File.rename(listfiletmp,listfile)
           end
@@ -938,23 +964,37 @@
     # - name: alias for SVN checkout
     # - tag: previous tag to check for changes, default nil
     # - trimSlash: whether to trim trailing '/', default true
+    # - getEpoch: whether to return the epoch if present, default false
     # @return tag, Array of names
     # or tag, nil if unchanged
     # or Exception if error
     # The tag should be regarded as opaque
-    def self.getlisting(name, tag=nil, trimSlash = true)
+    def self.getlisting(name, tag=nil, trimSlash = true, getEpoch = false)
       listfile, _ = self.listingNames(name)
-      curtag = "%s:%d" % [trimSlash, File.mtime(listfile)]
+      curtag = "%s:%s:%d" % [trimSlash, getEpoch, File.mtime(listfile)]
       if curtag == tag
         return curtag, nil
       else
         open(listfile) do |l|
           # fetch the file revision from the first line
           _filerev = l.gets.chomp # TODO should we be checking _filerev?
-          if trimSlash
-            return curtag, l.readlines.map {|x| x.chomp.chomp('/')}
+          if _filerev.start_with?(EPOCH_TAG)
+            if getEpoch
+              trimEpoch = -> l { l.split(EPOCH_SEP,2) } # return as array
+            else
+              trimEpoch = -> l { l.split(EPOCH_SEP,2)[1] } # strip the epoch
+            end
           else
-            return curtag, l.readlines.map(&:chomp)
+            trimEpoch = nil
+          end
+          if trimSlash
+            list = l.readlines.map {|x| x.chomp.chomp('/')}
+            list = list.map(&trimEpoch) if trimEpoch
+            return curtag, list
+          else
+            list = l.readlines.map(&:chomp)
+            list = list.map(&trimEpoch) if trimEpoch
+            return curtag, list
           end
         end
       end
diff --git a/repository.yml b/repository.yml
index 1e3516e..8530b98 100644
--- a/repository.yml
+++ b/repository.yml
@@ -34,6 +34,10 @@
   Meetings:
     url: private/foundation/Meetings
 
+  acreq: # for account requests
+    url: infra/infrastructure/trunk/acreq
+    depth: skip
+
   apachecon:
     url: private/foundation/ApacheCon
     depth: empty
@@ -79,6 +83,10 @@
     url: asf/comdev/site/trunk/content/speakers/talks
     depth: files
 
+  committers:
+    url: private/committers
+    depth: skip
+
   conflict-of-interest:
     url: private/documents/conflict-of-interest
     depth: skip
@@ -99,10 +107,11 @@
     depth: delete
     list: true
 
-  emeritus-requests-received:
+  emeritus-requests-received: # listing has dates for age checks
     url: private/documents/emeritus-requests-received
     depth: delete
     list: true
+    dates: true
 
   emeritus-requests-rescinded:
     url: private/documents/emeritus-requests-rescinded
@@ -190,3 +199,7 @@
 
   steve:
     url: asf/steve/trunk
+
+  subreq: # for mail subscription requests
+    url: infra/infrastructure/trunk/subreq
+    depth: skip
diff --git a/tools/collate_minutes.rb b/tools/collate_minutes.rb
index 5e0a200..c665bf7 100755
--- a/tools/collate_minutes.rb
+++ b/tools/collate_minutes.rb
@@ -43,8 +43,7 @@
 resources.each do |const, location|
   Kernel.const_set const, ASF::SVN[location]
   unless Kernel.const_get const
-    STDERR.puts 'Unable to locate local checkout for ' +
-      "https://svn.apache.org/repos/#{location}"
+    STDERR.puts 'Unable to locate local checkout for ' + location
     exit 1
   end
 end
@@ -826,8 +825,7 @@
           href = "http://apache.org/foundation/records/minutes/" +
             "#{report.meeting[0...4]}/board_minutes_#{report.meeting}.txt"
         else
-          href = 'https://svn.apache.org/repos/private/foundation/board/' +
-            "board_minutes_#{report.meeting}.txt"
+          href = ASF::SVN.svnpath!('foundation_board', "board_minutes_#{report.meeting}.txt")
         end
 
         x.a Date.parse(report.meeting.gsub('_','/')).strftime("%d %b %Y"),
diff --git a/tools/pubsub.rb b/tools/pubsub.rb
index c79e358..7c2445f 100644
--- a/tools/pubsub.rb
+++ b/tools/pubsub.rb
@@ -28,7 +28,6 @@
 options.pidfile = "/var/run/#{script}.pid"
 options.streamURL = 'http://pubsub.apache.org:2069/git/'
 options.puppet = false
-# options.streamURL = 'http://svn.apache.org:2069/commits'
 
 optionparser = OptionParser.new do |opts|
   opts.on '-u', '--user id', "Optional user to run #{script} as" do |user|
diff --git a/tools/pubsub2rake.rb b/tools/pubsub2rake.rb
new file mode 100755
index 0000000..45905e2
--- /dev/null
+++ b/tools/pubsub2rake.rb
@@ -0,0 +1,185 @@
+#!/usr/bin/env ruby
+
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+
+require 'net/http'
+require 'json'
+require 'thread'
+require 'whimsy/asf/config'
+require 'whimsy/asf/svn'
+
+def stamp(s)
+  "%s: %s" % [Time.now.gmtime.to_s, s]
+end
+
+class PubSub
+
+  require 'fileutils'
+  ALIVE = "/tmp/#{File.basename(__FILE__)}.alive" # TESTING ONLY
+
+  @restartable = false
+  @updated = false
+  def self.listen(url, creds, options={})
+    debug = options[:debug]
+    mtime = File.mtime(__FILE__)
+    done = false
+    ps_thread = Thread.new do
+      begin
+        uri = URI.parse(url)
+        Net::HTTP.start(uri.host, uri.port,
+          open_timeout: 20, read_timeout: 20, ssl_timeout: 20,
+          use_ssl: url.match(/^https:/) ? true : false) do |http|
+          request = Net::HTTP::Get.new uri.request_uri
+          request.basic_auth *creds if creds
+          http.request request do |response|
+            response.each_header do |h,v|
+              puts stamp [h,v].inspect if h.start_with? 'x-' or h == 'server'
+            end
+            body = ''
+            response.read_body do |chunk|
+              FileUtils.touch(ALIVE) # Temporary debug
+              body += chunk
+              # All chunks are terminated with \n. Since 2070 can split events into 64kb sub-chunks
+              # we wait till we have gotten a newline, before trying to parse the JSON.
+              if chunk.end_with? "\n"
+                event = JSON.parse(body.chomp)
+                body = ''
+                if event['stillalive']  # pingback
+                  @restartable = true
+                  puts(stamp event) if debug
+                else
+                  yield event
+                end
+              else
+                puts(stamp "Partial chunk") if debug
+              end
+              unless mtime == File.mtime(__FILE__)
+                puts stamp "File updated" if debug
+                @updated = true
+                done = true
+              end
+              break if done
+            end # reading chunks
+            puts stamp "Done reading chunks" if debug
+            break if done
+          end # read response
+          puts stamp "Done reading response" if debug
+          break if done
+        end # net start
+        puts stamp "Done with start" if debug
+      rescue Errno::ECONNREFUSED => e
+        @restartable = true
+        STDERR.puts stamp e.inspect
+        sleep 3
+      rescue Exception => e
+        STDERR.puts stamp e.inspect
+        STDERR.puts stamp e.backtrace
+      end
+      puts stamp "Done with thread" if debug
+    end # thread
+    puts stamp "Pubsub thread started #{url} ..."
+    ps_thread.join
+    puts stamp "Pubsub thread finished %s..." % (@updated ? '(updated) ' : '')
+    if @restartable
+      STDERR.puts stamp 'restarting'
+    
+      # relaunch script after a one second delay
+      sleep 1
+      exec RbConfig.ruby, __FILE__, *ARGV
+    end
+  end
+end
+
+if $0 == __FILE__
+  $stdout.sync = true
+
+  $hits = 0 # items matched
+  $misses = 0 # items not matched
+
+  # Cannot use shift as ARGV is needed for a relaunch
+  pubsub_URL = ARGV[0]  || 'https://pubsub.apache.org:2070/svn'
+  pubsub_FILE = ARGV[1] || File.join(Dir.home,'.pubsub')
+  pubsub_CRED = File.read(pubsub_FILE).chomp.split(':') rescue nil
+
+  WATCH=Hash.new{|h,k| h[k] = Array.new}
+  # determine which paths were are interested in
+  # depth: 'skip' == ignore completely
+  # files: only need to update if path matches one of the files
+  # depth: 'files' only need to update for top level files
+  
+  # The first segment of the url is the repo name, e.g. asf or infra
+  # The second segment is the subdir within the repo.
+  # The combination of the two are used for the pubsub path, for example:
+  # asf/infrastructure/site/trunk/content/foundation/records/minutes
+  # relates to pubsub_path: svn/asf/infrastructure
+
+  def process(event)
+    path = event['pubsub_path']
+    if WATCH.include? path # WATCH auto-vivifies
+      $hits += 1
+      matches = Hash.new{|h,k| h[k] = Array.new} # key alias, value = array of matching files
+      watching = WATCH[path]
+      watching.each do |svn_prefix, svn_alias, files|
+        changed = event['commit']['changed']
+        changed.keys.each do |ck|
+          if ck.start_with? svn_prefix # file matches target path
+            if files && files.size > 0 # but does it match exactly?
+              files.each do |file|
+                if ck == File.join(svn_prefix, file)
+                  matches[svn_alias] << ck
+                  break # no point checking other files
+                end
+              end
+            else 
+              matches[svn_alias] << ck
+            end
+          end
+        end
+      end
+      matches.each do |k,v|
+        puts stamp "Updating #{k} #{$hits}/#{$misses}"
+        cmd = ['rake', "svn:update[#{k}]"]
+        unless system(*cmd, {chdir: '/srv/whimsy'})
+          puts stamp "Error #{$?} processing #{cmd}"
+        end
+      end
+    else
+      $misses += 1
+    end # possible match
+  end
+
+  ASF::SVN.repo_entries(true).each do |name,desc|
+    next if desc['depth'] == 'skip' # not needed
+    url = desc['url']
+
+    one,two,three=url.split('/',3)
+    path_prefix = one == 'private' ? ['/private','svn'] : ['/svn'] 
+    pubsub_key = [path_prefix,one,two,'commit'].join('/')
+    svn_relpath = [two,three].join('/')
+    WATCH[pubsub_key] << [svn_relpath, name, desc['files']]
+  end
+
+  if pubsub_URL == 'WATCH' # dump keys for use in constructing URL
+    p WATCH.keys
+    exit
+  end
+
+  if File.exist? pubsub_URL
+    puts "** Unit testing **"
+    open(pubsub_URL).each_line do |line|
+      event = nil
+      begin
+      event = JSON.parse(line.chomp)
+      rescue Exception => e
+        p e
+        puts line
+      end
+      process(event) unless event == nil || event['stillalive']
+    end
+  else
+    puts stamp(pubsub_URL)
+    PubSub.listen(pubsub_URL,pubsub_CRED) do | event |
+      process(event)
+    end
+  end
+end 
\ No newline at end of file
diff --git a/tools/setup_local_repo.rb b/tools/setup_local_repo.rb
index 8aa9d8d..74db754 100755
--- a/tools/setup_local_repo.rb
+++ b/tools/setup_local_repo.rb
@@ -40,6 +40,7 @@
   end
 
   # for each file, if it does not exist, copy the file from the ASF repo
+  # TODO it might be better to copy from samples
   (entry['files'] || []).each do |file|
     filepath = File.join(svndir,file)
     revision, err = ASF::SVN.getRevision(filepath)
@@ -57,5 +58,10 @@
       end
     end
   end
-
+  # Also need basic versions of:
+  # grants.txt
+  # cclas.txt
+  # Meetings/yyyymmdd/memapp-received.txt where yyyymmdd is within the time limit (32 days?)
+  #  acreq/new-account-reqs.txt
+  # foundation_board/board_agenda_2020_08_19.txt (e.g.)
 end
diff --git a/www/board/agenda/models/minutes.rb b/www/board/agenda/models/minutes.rb
index c3ebf8e..fa916c8 100755
--- a/www/board/agenda/models/minutes.rb
+++ b/www/board/agenda/models/minutes.rb
@@ -134,18 +134,12 @@
         if title =~ /Action Items/
           comments = notes.gsub(/\r\n/,"\n").gsub(/^/,'    ')
         elsif title == 'Adjournment'
-          if notes =~ /^1[01]:\d\d/
-            comments = "\n    Adjourned at #{notes} a.m. (Pacific)\n"
-          elsif notes =~ /^\d\d:\d\d/
-            comments = "\n    Adjourned at #{notes} p.m. (Pacific)\n"
-          else
-            comments += "\n" + notes.to_s.reflow(4,68) + "\n"
-          end
+          comments = "\n    Adjourned at #{notes} UTC\n"
         else
           comments += "\n" + notes.to_s.reflow(4,68) + "\n"
         end
       elsif title == 'Adjournment'
-        comments = "\n    Adjourned at ??:?? a.m. (Pacific)\n"
+        comments = "\n    Adjourned at ??:?? UTC\n"
       end
       [attach, title, comments]
     end
@@ -158,7 +152,7 @@
     minutes.sub! 'Minutes (in Subversion) are found under the URL:',
       'Published minutes can be found at:'
 
-    minutes.sub! 'https://svn.apache.org/repos/private/foundation/board/',
+    minutes.sub! ASF::SVN.svnpath!('foundation_board'),
       'http://www.apache.org/foundation/board/calendar.html'
 
     minutes.sub!(/ \d\. Committee Reports.*?\n\s+A\./m) do |heading|
diff --git a/www/board/agenda/routes.rb b/www/board/agenda/routes.rb
index d507c3d..4b49b0e 100755
--- a/www/board/agenda/routes.rb
+++ b/www/board/agenda/routes.rb
@@ -547,7 +547,14 @@
   template = File.join(ASF::SVN['foundation_board'], 'templates', 'board_agenda.erb')
   @disabled = dir("board_agenda_*.txt").
     include? @meeting.strftime("board_agenda_%Y_%m_%d.txt")
-  @agenda = Erubis::Eruby.new(IO.read(template)).result(binding)
+
+  begin
+    @agenda = Erubis::Eruby.new(IO.read(template)).result(binding)
+  rescue => error
+    status 500
+    STDERR.puts error
+    return "error in #{template} in: #{error}"
+  end
 
   @cssmtime = File.mtime('public/stylesheets/app.css').to_i
   _html :new
diff --git a/www/board/agenda/spec/secretary_spec.rb b/www/board/agenda/spec/secretary_spec.rb
index 0e9c0b1..9ffb344 100644
--- a/www/board/agenda/spec/secretary_spec.rb
+++ b/www/board/agenda/spec/secretary_spec.rb
@@ -83,7 +83,7 @@
       expect(draft).to include('@Sam: Is anyone on the PMC looking at the reminders?')
       expect(draft).to include('No report was submitted.')
       expect(draft).to include('was approved by Unanimous Vote of the directors present.')
-      expect(draft).to match(/Adjourned at \d+:\d\d [ap]\.m\. \(Pacific\)/)
+      expect(draft).to match(/Adjourned at \d+:\d\d UTC/)
 
       @agenda = 'board_agenda_2015_02_18.txt'
       @message = 'Draft minutes for 2015-02-18'
diff --git a/www/board/agenda/test/data/board_minutes_2015_01_21.txt b/www/board/agenda/test/data/board_minutes_2015_01_21.txt
index a549c20..df2ca9a 100644
--- a/www/board/agenda/test/data/board_minutes_2015_01_21.txt
+++ b/www/board/agenda/test/data/board_minutes_2015_01_21.txt
@@ -859,7 +859,7 @@
 
 13. Adjournment
 
-    Adjourned at 11:34 a.m. (Pacific)
+    Adjourned at 11:34 UTC
 
 ============
 ATTACHMENTS:
diff --git a/www/board/agenda/views/actions/publish.json.rb b/www/board/agenda/views/actions/publish.json.rb
index ec1e8d3..82ac0a0 100755
--- a/www/board/agenda/views/actions/publish.json.rb
+++ b/www/board/agenda/views/actions/publish.json.rb
@@ -68,7 +68,7 @@
 end
 
 # Update the Calendar from SVN
-ASF::SVN.multiUpdate_ ASF::SVN.svnpath!('site-board', 'calendar.mdtext' ), @message, env, _ do |calendar|
+ASF::SVN.update ASF::SVN.svnpath!('site-board', 'calendar.mdtext' ).untaint, @message, env, _ do |calendar|
   # add year header
   unless calendar.include? "##{year}"
     calendar[/^()#.*Board meeting minutes #/,1] =
diff --git a/www/board/agenda/views/actions/todos.json.rb b/www/board/agenda/views/actions/todos.json.rb
index a9fa719..9515ce6 100644
--- a/www/board/agenda/views/actions/todos.json.rb
+++ b/www/board/agenda/views/actions/todos.json.rb
@@ -189,7 +189,7 @@
       body "Dear new PMC chairs,\n\nCongratulations on your new role at " +
       "Apache. I've changed your LDAP privileges to reflect your new " +
       "status.\n\nPlease read this and update the foundation records:\n" +
-      "https://svn.apache.org/repos/private/foundation/officers/advice-for-new-pmc-chairs.txt" +
+      "#{ASF::SVN.svnpath!('officers', 'advice-for-new-pmc-chairs.txt')}" +
       "\n\nWarm regards,\n\n#{sender.public_name}"
     end
 
diff --git a/www/board/subscriptions.cgi b/www/board/subscriptions.cgi
index 275b441..9b3440a 100755
--- a/www/board/subscriptions.cgi
+++ b/www/board/subscriptions.cgi
@@ -28,9 +28,9 @@
           _ "This script takes the list of subscribers (updated #{modtime}) to "
           _a 'board@apache.org', href: 'https://mail-search.apache.org/members/private-arch/board/'
           _ ' which are matched against '
-          _a 'members.txt', href: 'https://svn.apache.org/repos/private/foundation/members.txt'
+          _a 'members.txt', href: ASF::SVN.svnpath!('foundation', 'members.txt')
           _ ', '
-          _a 'iclas.txt', href: 'https://svn.apache.org/repos/private/foundation/officers/iclas.txt'
+          _a 'iclas.txt', href: ASF::SVN.svnpath!('officers', 'iclas.txt')
           _ ', and '
           _code 'ldapsearch mail'
           _ ' to match each email address to an Apache ID.  '
@@ -47,7 +47,7 @@
         end
         _p! do
           _ 'The resulting list is then cross-checked against '
-          _a 'committee-info.text', href: 'https://svn.apache.org/repos/private/committers/board/committee-info.txt'
+          _a 'committee-info.text', href: ASF::SVN.svnpath!('board', 'committee-info.txt')
           _ ' and '
           _code 'ldapsearch cn=pmc-chairs'
           _ '.  Membership that is only listed in one of these two sources is '
diff --git a/www/brand/replyedit.cgi b/www/brand/replyedit.cgi
index b4fc49a..c0140b4 100755
--- a/www/brand/replyedit.cgi
+++ b/www/brand/replyedit.cgi
@@ -12,7 +12,7 @@
       title: PAGETITLE,
       related: {
         "https://www.apache.org/foundation/marks/resources" => "Trademark Site Map",
-        "https://svn.apache.org/repos/private/foundation/Brand/runbook.txt" => "Members-only Trademark runbook",
+        ASF::SVN.svnpath!('foundation', 'Brand', 'runbook.txt') => "Members-only Trademark runbook",
         "https://lists.apache.org/list.html?trademarks@apache.org" => "Ponymail interface to trademarks@"
       },
       helpblock: -> {
@@ -20,7 +20,7 @@
           _ 'This is a wireframe '
           _strong 'DEMO'
           _ ' of a proposed editing ui for Boilerplate Reply to a previously selected question. See '
-          _a 'Proposed how to reply guide', href: 'https://svn.apache.org/repos/private/foundation/Brand/replies-readme.md'
+          _a 'Proposed how to reply guide', href: ASF::SVN.svnpath!('foundation', 'Brand', 'replies-readme.md')
         end
         _p do
           _ 'This would allow the user to edit the selected boilerplate reply, including options of how/when to send, and a button to actually submit a reply for sending. '
diff --git a/www/brand/replylist.cgi b/www/brand/replylist.cgi
index b854be2..5f305cc 100755
--- a/www/brand/replylist.cgi
+++ b/www/brand/replylist.cgi
@@ -12,7 +12,7 @@
       title: PAGETITLE,
       related: {
         "https://www.apache.org/foundation/marks/resources" => "Trademark Site Map",
-        "https://svn.apache.org/repos/private/foundation/Brand/runbook.txt" => "Members-only Trademark runbook",
+        ASF::SVN.svnpath!('foundation', 'Brand', 'runbook.txt') => "Members-only Trademark runbook",
         "https://lists.apache.org/list.html?trademarks@apache.org" => "Ponymail interface to trademarks@"
       },
       helpblock: -> {
@@ -22,7 +22,7 @@
           _ ' of a proposed tool to allow ASF Members to review incoming questions on a private mailing list, and then select a '
           _em 'boilerplate reply'
           _ ' to send to an original questioner. See '
-          _a 'Proposed how to reply guide', href: 'https://svn.apache.org/repos/private/foundation/Brand/replies-readme.md'
+          _a 'Proposed how to reply guide', href: ASF::SVN.svnpath!('foundation', 'Brand', 'replies-readme.md')
         end
         _p do
           _ 'This list would display the last 30 (or so) days of messages on a private list, and have UI that shows info about the messages, plus action buttons to create a reply, see: '
diff --git a/www/brand/replyui.cgi b/www/brand/replyui.cgi
index 16a679c..55a0b5e 100755
--- a/www/brand/replyui.cgi
+++ b/www/brand/replyui.cgi
@@ -12,7 +12,7 @@
       title: PAGETITLE,
       related: {
         "https://www.apache.org/foundation/marks/resources" => "Trademark Site Map",
-        "https://svn.apache.org/repos/private/foundation/Brand/runbook.txt" => "Members-only Trademark runbook",
+        ASF::SVN.svnpath!('foundation', 'Brand', 'runbook.txt')  => "Members-only Trademark runbook",
         "https://lists.apache.org/list.html?trademarks@apache.org" => "Ponymail interface to trademarks@"
       },
       helpblock: -> {
@@ -20,7 +20,7 @@
           _ 'This is a wireframe '
           _strong 'DEMO'
           _ ' of a proposed dialog/popup way to Choose a specific Boilerplate Reply to a previously selected question. See '
-          _a 'Proposed how to reply guide', href: 'https://svn.apache.org/repos/private/foundation/Brand/replies-readme.md'
+          _a 'Proposed how to reply guide', href: ASF::SVN.svnpath!('foundation', 'Brand', 'replies-readme.md')
         end
         _p do
           _ 'This would be some listing of available Boilerplates with descriptions about each, so the user could choose one; that would then open it for editing as a Reply-All message to save. '
diff --git a/www/committers/index.cgi b/www/committers/index.cgi
index 469a59d..d49c2ea 100755
--- a/www/committers/index.cgi
+++ b/www/committers/index.cgi
@@ -20,7 +20,7 @@
       relatedtitle: 'More Useful Links',
       related: {
         "/committers/tools" => "Whimsy All Tools Listing",
-        "https://svn.apache.org/repos/private/committers/" => "Checkout the private 'committers' repo for Committers",
+        ASF::SVN.svnpath!('committers') => "Checkout the private 'committers' repo for Committers",
         "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => "See This Source Code",
         "mailto:dev@whimsical.apache.org?subject=[FEEDBACK] members/index idea" => "Email Feedback To dev@whimsical"
       },
diff --git a/www/committers/subscribe.cgi b/www/committers/subscribe.cgi
index 1994b98..3f69eb6 100755
--- a/www/committers/subscribe.cgi
+++ b/www/committers/subscribe.cgi
@@ -237,7 +237,7 @@
           _pre request
         end
         
-        SUBREQ = 'https://svn.apache.org/repos/infra/infrastructure/trunk/subreq/'
+        SUBREQ = ASF::SVN.svnpath!('subreq')
         SUBREQ.sub! '/subreq', '/unsubreq' if @request == 'unsub'
         
         rc = 999
diff --git a/www/incubator/graduated.cgi b/www/incubator/graduated.cgi
index 7a2a9ba..0b8937d 100755
--- a/www/incubator/graduated.cgi
+++ b/www/incubator/graduated.cgi
@@ -45,10 +45,10 @@
             href: 'https://whimsy.apache.org/board/minutes/'
           _ ', '
           _a 'committee-info.txt',
-            href: 'https://svn.apache.org/repos/private/committers/board/committee-info.txt'
+            href: ASF::SVN.svnpath!('board', 'committee-info.txt')
           _ ',  and '
           _a 'podlings.xml',
-            href: 'https://svn.apache.org/repos/asf/incubator/public/trunk/content/podlings.xml'
+            href: ASF::SVN.svnpath!('incubator-content', 'podlings.xml')
           _ '.'
           _p do
             _ul do
diff --git a/www/incubator/podling-crosscheck.cgi b/www/incubator/podling-crosscheck.cgi
index 853889e..c27e228 100755
--- a/www/incubator/podling-crosscheck.cgi
+++ b/www/incubator/podling-crosscheck.cgi
@@ -31,7 +31,7 @@
             href: '../../roster/committee/incubator'
           _ ', '
           _a 'mentor lists in podlings.xml',
-            href: 'https://svn.apache.org/repos/asf/incubator/public/trunk/content/podlings.xml'
+            href: ASF::SVN.svnpath!('incubator-content', 'podlings.xml')
           _ ',  and '
           _a 'Podling lists in LDAP',
             href: '../../roster/ppmc'
diff --git a/www/infra/chunktest.cgi b/www/infra/chunktest.cgi
new file mode 100755
index 0000000..a39aaa2
--- /dev/null
+++ b/www/infra/chunktest.cgi
@@ -0,0 +1,19 @@
+#!/usr/bin/env ruby
+
+# Test for asfpy chunking
+
+require 'json'
+
+print "Content-type: text/plain; charset=UTF-8\r\n\r\n"
+
+$stdout.sync = true
+
+data = {
+  data: '1234567890' * 2000
+}
+out = JSON.generate(data)+"\n"
+
+3.times do
+  print out
+  sleep 5
+end
diff --git a/www/members/inactive.cgi b/www/members/inactive.cgi
index b9a0977..7d5a0ab 100755
--- a/www/members/inactive.cgi
+++ b/www/members/inactive.cgi
@@ -53,7 +53,7 @@
         'https://www.apache.org/foundation/governance/meetings' => 'How Meetings & Voting Works',
         '/members/proxy' => 'Assign A Proxy For Next Meeting',
         '/members/non-participants' => 'Members Not Participating',
-        'https://svn.apache.org/repos/private/foundation/members.txt' => 'See Official Members.txt File',
+        ASF::SVN.svnpath!('foundation','members.txt') => 'See Official Members.txt File',
         MeetingUtil::RECORDS => 'Official Past Meeting Records'
       },
       helpblock: -> {
@@ -176,7 +176,7 @@
             _tr_ class: color do
               _td do
                 _a date, href:
-                  'https://svn.apache.org/repos/private/foundation/Meetings/' + date
+                  ASF::SVN.svnpath!('Meetings') + date
               end
               case status
               when 'A'
diff --git a/www/members/index.cgi b/www/members/index.cgi
index a741fb9..1e08a2e 100755
--- a/www/members/index.cgi
+++ b/www/members/index.cgi
@@ -43,7 +43,7 @@
       relatedtitle: 'More Useful Links',
       related: {
         "/committers/tools" => "Whimsy All Available Tools Listing",
-        "https://svn.apache.org/repos/private/foundation/" => "Checkout the private 'foundation' repo for Members",
+        ASF::SVN.svnpath!('foundation') => "Checkout the private 'foundation' repo for Members",
         "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => "See This Source Code",
         "mailto:dev@whimsical.apache.org?subject=[FEEDBACK] members/index idea" => "Email Feedback To dev@whimsical"
       },
diff --git a/www/members/meeting.cgi b/www/members/meeting.cgi
index 13faec5..ad08a51 100755
--- a/www/members/meeting.cgi
+++ b/www/members/meeting.cgi
@@ -8,7 +8,6 @@
 require 'json'
 require 'wunderbar/jquery/stupidtable'
 require_relative 'meeting-util'
-FOUNDATION_SVN = 'https://svn.apache.org/repos/private/foundation/'
 DTFORMAT = '%A, %d %B %Y at %H:%M %z'
 TADFORMAT = '%Y%m%dT%H%M%S'
 
@@ -157,7 +156,7 @@
         _p do
           _span.text_warning 'REMINDER: '
           _ "Voting ballots are sent to your official email address as found in members.txt, please double-check it is correct!"
-          _a 'See members.txt', href: "#{FOUNDATION_SVN}members.txt"
+          _a 'See members.txt', href: ASF::SVN.svnpath!('foundation', 'members.txt')
         end
       end
 
@@ -290,7 +289,7 @@
           _ ' You '
           _b 'must' 
           _ ' send an email with '
-          _a 'foundation/membership-application-email.txt', href: "#{FOUNDATION_SVN}membership-application-email.txt"
+          _a 'foundation/membership-application-email.txt', href: ASF::SVN.svnpath!('foundation', 'membership-application-email.txt')
           _ ' to formally invite the new member to fill out the application form.  Applications must be signed and submitted to the secretary within 30 days of the meeting to be valid.'
         end
       end
diff --git a/www/members/namediff.cgi b/www/members/namediff.cgi
index 6516fe3..2620506 100755
--- a/www/members/namediff.cgi
+++ b/www/members/namediff.cgi
@@ -12,7 +12,7 @@
       title: PAGETITLE,
       related: {
         '/roster/members' => 'Listing Of All Members',
-        'https://svn.apache.org/repos/private/foundation/officers/iclas.txt' => 'ICLA.txt Listing',
+        ASF::SVN.svnpath!('officers', 'iclas.txt') => 'ICLA.txt Listing',
       },
       helpblock: -> {
         _p_ do
diff --git a/www/members/nominations.cgi b/www/members/nominations.cgi
index 3f2e716..96fd18b 100755
--- a/www/members/nominations.cgi
+++ b/www/members/nominations.cgi
@@ -70,7 +70,7 @@
       related: {
         '/members/memberless-pmcs' => 'PMCs with no/few ASF Members',
         '/members/watch' => 'Watch list for potential Member candidates',
-        'https://svn.apache.org/repos/private/foundation/Meetings/' => 'Official Meeting Agenda Directory'
+        ASF::SVN.svnpath!('Meetings') => 'Official Meeting Agenda Directory'
       },
       helpblock: -> {
         _ 'This script checks new member nomination statements from members@ against the official meeting ballot files, and highlights differences. '
diff --git a/www/members/subscriptions.cgi b/www/members/subscriptions.cgi
index efd9779..af378ad 100755
--- a/www/members/subscriptions.cgi
+++ b/www/members/subscriptions.cgi
@@ -25,9 +25,9 @@
           _a 'members@apache.org', href: 'https://mail-search.apache.org/members/private-arch/members/'
           _br
           _ 'These are matched against '
-          _a 'members.txt', href: 'https://svn.apache.org/repos/private/foundation/members.txt'
+          _a 'members.txt', href: ASF::SVN.svnpath!('foundation', 'members.txt') 
           _ ', '
-          _a 'iclas.txt', href: 'https://svn.apache.org/repos/private/foundation/officers/iclas.txt'
+          _a 'iclas.txt', href: ASF::SVN.svnpath!('officers', 'iclas.txt')
           _ ', and '
           _code 'ldapsearch mail'
           _ ' to attempt to match the email address to an Apache ID.'
diff --git a/www/members/watch.cgi b/www/members/watch.cgi
index dc82ff3..bea018e 100755
--- a/www/members/watch.cgi
+++ b/www/members/watch.cgi
@@ -9,7 +9,6 @@
 require 'wunderbar/bootstrap'
 require 'wunderbar/jquery/stupidtable'
 
-SVN_BOARD = "https://svn.apache.org/repos/private/foundation/board"
 meetings = ASF::SVN['Meetings']
 
 _html do
@@ -23,7 +22,7 @@
       related: {
         '/members/memberless-pmcs' => 'PMCs with no/few ASF Members',
         '/members/nominations' => 'Members Meeting Nomination Crosscheck',
-        'https://svn.apache.org/repos/private/foundation/Meetings/' => 'Official Meeting Agenda Directory'
+        ASF::SVN.svnpath!('Meetings') => 'Official Meeting Agenda Directory'
       },
       helpblock: -> {
         _ 'To help evaluate potential Member candidates, here are a number of ways to see where non-Members are participating broadly at the ASF.'
@@ -277,7 +276,7 @@
                   Dir[File.join(board, 'board_agenda_*')].sort.each do |agenda|
                     agenda.untaint
                     if File.read(agenda).include? search_string
-                      minutes = File.join(SVN_BOARD, File.basename(agenda))
+                      minutes = ASF::SVN.svnpath!('foundation_board', File.basename(agenda))
                       date = agenda.gsub('_','-')[/(\d+-\d+-\d+)/,1]
                       break
                     end
diff --git a/www/officers/acreq.cgi b/www/officers/acreq.cgi
index 23a9b19..3c252de 100755
--- a/www/officers/acreq.cgi
+++ b/www/officers/acreq.cgi
@@ -8,9 +8,6 @@
 require 'whimsy/asf'
 require 'mail'
 require 'date'
-require 'open3'
-require 'tmpdir'
-require 'shellwords'
 
 user = ASF::Auth.decode(env = {})
 unless user.asf_member? or ASF.pmc_chairs.include? user
@@ -19,15 +16,12 @@
   exit
 end
 
-ACREQ = 'https://svn.apache.org/repos/infra/infrastructure/trunk/acreq'
-OFFICERS = 'https://svn.apache.org/repos/private/foundation/officers'
+ICLAS = ASF::SVN.svnpath!('officers', 'iclas.txt')
 
 # get up to date data...
-# TODO replace with library method see WHIMSY-103
-SVN = ("svn --username #{Shellwords.escape env.user} " +
-  "--password #{Shellwords.escape env.password}").untaint
-requests = `#{SVN} cat #{ACREQ}/new-account-reqs.txt`
-iclas_txt = `#{SVN} cat #{OFFICERS}/iclas.txt`.force_encoding('utf-8')
+requests, err = ASF::SVN.svn('cat', ASF::SVN.svnpath!('acreq', 'new-account-reqs.txt'), {env: env})
+
+iclas_txt,err = ASF::SVN.svn('cat', ICLAS, {env: env}).force_encoding('utf-8')
 
 # grab the current list of PMCs from ldap
 pmcs = ASF::Committee.pmcs.map(&:name).sort
@@ -54,11 +48,10 @@
   iclas = {email => $1}
 else
   count = iclas ? iclas.to_i : 300 rescue 300
-  oldrev = \
-    `#{SVN} log --incremental -q -r HEAD:0 -l#{count} -- #{OFFICERS}/iclas.txt`.
-    split("\n")[-1].split()[0][1..-1].to_i
-  iclas = Hash[*`#{SVN} diff -r #{oldrev}:HEAD -- #{OFFICERS}/iclas.txt`.
-    scan(/^[+]notinavail:.*?:(.*?):(.*?):Signed CLA/).flatten.reverse]
+  log, err = ASF::SVN.svn(['log', '--incremental', '-q', "-l#{count}"], ICLAS, {revision: 'HEAD:0', env: env})
+  oldrev = log.split("\n")[-1].split()[0][1..-1].to_i
+  diff, err = ASF::SVN.svn('diff', ICLAS, {revision: "#{oldrev}:HEAD", env: env})
+  iclas = Hash[*diff.scan(/^[+]notinavail:.*?:(.*?):(.*?):Signed CLA/).flatten.reverse]
 end
 
 # grab the list of userids that have been assigned (for validation purposes)
@@ -339,29 +332,16 @@
                     Using #{ENV['HTTP_USER_AGENT']}
                   EOF
 
-                  Dir.mktmpdir do |tmpdir|
-                    tmpdir.untaint
-
-                    # Checkout the ACREQ directory
-                    `#{SVN} co #{ACREQ} #{tmpdir}`
-
-                    # Update the new-account-reqs file...
-                    File.open(File.join(tmpdir, 'new-account-reqs.txt'), 'a') do |file|
-                      file.puts(line)
-                    end
-
-                    # and commit the change ...
+                  msg = "#{@user} account request by #{user.id} for #{requestor}"
+                  rc = ASF::SVN.update(ASF::SVN.svnpath!('acreq', 'new-account-reqs.txt'), msg, env, _) do |dir, input|
                     _h2 'Commit messages'
-                    rc = ASF::SVN.svn_('commit', File.join(tmpdir, 'new-account-reqs.txt'), _,
-                          {msg: "#{@user} account request by #{user.id} for #{requestor}", env: env})
-
-                    if rc == 0
-                      mail.deliver!
-                    else
-                      tobe = 'that would have been '
-                    end
+                    input + line + "\n"
                   end
-
+                  if rc == 0
+                    mail.deliver!
+                  else
+                    tobe = 'that would have been '
+                  end
                   # report on status
                   _h2 "New entry #{tobe}added:"
                   _pre line
diff --git a/www/officers/index.cgi b/www/officers/index.cgi
index bb052aa..7ba36f0 100755
--- a/www/officers/index.cgi
+++ b/www/officers/index.cgi
@@ -28,7 +28,7 @@
       relatedtitle: 'More Useful Links',
       related: {
         "/committers/tools" => "Whimsy All Available Tools Listing",
-        "https://svn.apache.org/repos/private/foundation/" => "Checkout the private 'foundation/officers' repo for Officers",
+        ASF::SVN.svnpath!('foundation', 'officers') => "Checkout the private 'foundation/officers' repo for Officers",
         "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => "See This Source Code",
         "mailto:dev@whimsical.apache.org?subject=[FEEDBACK] members/index idea" => "Email Feedback To dev@whimsical"
       },
diff --git a/www/officers/surveys.cgi b/www/officers/surveys.cgi
index 44225a2..647258d 100755
--- a/www/officers/surveys.cgi
+++ b/www/officers/surveys.cgi
@@ -36,7 +36,7 @@
   if asfsvn
     return 'officers_surveys'
   else
-    return 'https://svn.apache.org/repos/private/foundation/officers/surveys/'
+    return ASF::SVN.svnpath!('officers','surveys')
   end
 end
 
diff --git a/www/roster/models/committer.rb b/www/roster/models/committer.rb
index 9148165..db4ab32 100644
--- a/www/roster/models/committer.rb
+++ b/www/roster/models/committer.rb
@@ -2,6 +2,8 @@
 
 class Committer
 
+  SECS_TO_DAYS = 60*60*24
+
   def self.serialize(id, env)
     response = {}
 
@@ -149,22 +151,26 @@
 
         file = ASF::EmeritusFiles.find(person)
         if file
-          response[:forms][:emeritus] = ASF::SVN.svnpath!('emeritus', file)
+          response[:forms][:emeritus] = ASF::EmeritusFiles.svnpath!(file)
         end
 
-        file = ASF::EmeritusRequestFiles.find(person)
+        epoch, file = ASF::EmeritusRequestFiles.find(person, true)
         if file
-          response[:forms][:emeritus_request] = ASF::SVN.svnpath!('emeritus-requests-received', file)
+          response[:forms][:emeritus_request] = ASF::EmeritusRequestFiles.svnpath!(file)
+          # Calculate the age in days
+          response[:emeritus_request_age] = (((Time.now.to_i - epoch.to_i).to_f/SECS_TO_DAYS)).round(1).to_s
+        elsif epoch # listing does not have both epoch and file
+          response[:forms][:emeritus_request] = ASF::EmeritusRequestFiles.svnpath!(epoch)
         end
 
         file = ASF::EmeritusRescindedFiles.find(person)
         if file
-          response[:forms][:emeritus_rescinded] = ASF::SVN.svnpath!('emeritus-requests-rescinded', file)
+          response[:forms][:emeritus_rescinded] = ASF::EmeritusRescindedFiles.svnpath!(file)
         end
 
         file = ASF::EmeritusReinstatedFiles.find(person)
         if file
-          response[:forms][:emeritus_reinstated] = ASF::SVN.svnpath!('emeritus-reinstated', file)
+          response[:forms][:emeritus_reinstated] = ASF::EmeritusReinstatedFiles.svnpath!(file)
         end
 
       else
diff --git a/www/roster/views/committees.html.rb b/www/roster/views/committees.html.rb
index 5bd4516..1f743d4 100644
--- a/www/roster/views/committees.html.rb
+++ b/www/roster/views/committees.html.rb
@@ -11,7 +11,7 @@
     relatedtitle: 'More Useful Links',
     related: {
       "/committers/tools" => "Whimsy All Tools Listing",
-      "https://svn.apache.org/repos/private/committers/" => "Checkout the private 'committers' repo for Committers",
+      ASF::SVN.svnpath!('committers') => "Checkout the private 'committers' repo for Committers",
       "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => "See This Source Code",
       "mailto:dev@whimsical.apache.org?subject=[FEEDBACK] members/index idea" => "Email Feedback To dev@whimsical"
     },
diff --git a/www/roster/views/person/forms.js.rb b/www/roster/views/person/forms.js.rb
index a95bc9c..b9f1f64 100644
--- a/www/roster/views/person/forms.js.rb
+++ b/www/roster/views/person/forms.js.rb
@@ -48,6 +48,11 @@
                   _a 'Emeritus Request',
                     href: "#{link}"
                 end
+                emeritus_request_age = committer['emeritus_request_age']
+                if emeritus_request_age
+                  _ ' Days since submission: '
+                  _ emeritus_request_age
+                end
               end
             elsif form == 'emeritus_rescinded'
               _li do
diff --git a/www/secretary/emeritus_check.cgi b/www/secretary/emeritus_check.cgi
index 86aa16a..74c7188 100755
--- a/www/secretary/emeritus_check.cgi
+++ b/www/secretary/emeritus_check.cgi
@@ -49,7 +49,7 @@
     files.select {|k,v| v == 'NAK'}.sort_by{|k| k[0].split('-').pop}.each do |k,v|
       _tr do
         _td do
-          _a k, href: "https://svn.apache.org/repos/private/documents/emeritus/#{k}", target: '_blank'
+          _a k, href: ASF::SVN.svnpath!('emeritus', k), target: '_blank'
         end
       end
     end
@@ -77,7 +77,7 @@
         if person.icla && person.icla.claRef
           file = ASF::ICLAFiles.match_claRef(person.icla.claRef.untaint)
           if file
-            _a person.icla.claRef, href: "https://svn.apache.org/repos/private/documents/iclas/#{file}", target: '_blank'
+            _a person.icla.claRef, href: ASF::SVN.svnpath!('iclas', file), target: '_blank'
           else
             _ ''
           end
diff --git a/www/secretary/icla-lint.cgi b/www/secretary/icla-lint.cgi
index 986b701..353cbc4 100755
--- a/www/secretary/icla-lint.cgi
+++ b/www/secretary/icla-lint.cgi
@@ -211,7 +211,7 @@
             _td icla
             _td do
               paths.each do |path|
-                _a path, href: "https://svn.apache.org/repos/private/documents/iclas/#{path}"
+                _a path, href: ASF::SVN.svnpath!('iclas', path)
               end
             end
           end
@@ -258,7 +258,7 @@
         v.each do |p|
           _tr do
             _td do
-              _a k, href: "https://svn.apache.org/repos/private/documents/iclas/#{p}"
+              _a k, href: ASF::SVN.svnpath!('iclas', p)
             end
           end
         end
@@ -334,7 +334,7 @@
             _td do
               file = ASF::ICLAFiles.match_claRef(icla_)
               if file
-                _a icla_, href: "https://svn.apache.org/repos/private/documents/iclas/#{file}"
+                _a icla_, href: ASF::SVN.svnpath!('iclas', file)
               else
                 _ icla_
               end
diff --git a/www/secretary/ldap-check-committers.cgi b/www/secretary/ldap-check-committers.cgi
index fd67d79..029c37e 100755
--- a/www/secretary/ldap-check-committers.cgi
+++ b/www/secretary/ldap-check-committers.cgi
@@ -52,7 +52,7 @@
           if icla
             if icla.claRef
               _td do
-                _a icla.claRef, href: "https://svn.apache.org/repos/private/documents/iclas/#{icla.claRef}"
+                _a icla.claRef, href: ASF::SVN.svnpath!('iclas', icla.claRef)
               end
             else
               _td icla.form
diff --git a/www/secretary/ldap-check.cgi b/www/secretary/ldap-check.cgi
index 463eb4a..9930469 100755
--- a/www/secretary/ldap-check.cgi
+++ b/www/secretary/ldap-check.cgi
@@ -140,7 +140,7 @@
           if icla
             if icla.claRef
               _td do
-                _a icla.claRef, href: "https://svn.apache.org/repos/private/documents/iclas/#{icla.claRef}"
+                _a icla.claRef, href: ASF::SVN.svnpath!('iclas', ASF::ICLAFiles.match_claRef(icla.claRef))
               end
             else
               _td icla.form
diff --git a/www/secretary/ldap-names.cgi b/www/secretary/ldap-names.cgi
index ce15604..e74e119 100755
--- a/www/secretary/ldap-names.cgi
+++ b/www/secretary/ldap-names.cgi
@@ -149,7 +149,7 @@
         _td do
           file = ASF::ICLAFiles.match_claRef(claRef.untaint)
           if file
-            _a claRef, href: "https://svn.apache.org/repos/private/documents/iclas/#{file}"
+            _a claRef, href: ASF::SVN.svnpath!('iclas', file)
           else
             _ claRef
           end
diff --git a/www/secretary/memapp_check.cgi b/www/secretary/memapp_check.cgi
index 3bd87f6..605615b 100755
--- a/www/secretary/memapp_check.cgi
+++ b/www/secretary/memapp_check.cgi
@@ -59,7 +59,7 @@
     files.select {|k,v| v == 'NAK'}.sort_by{|k| k[0].split('-').pop}.each do |k,v|
       _tr do
         _td do
-          _a k, href: "https://svn.apache.org/repos/private/documents/member_apps/#{k}", target: '_blank'
+          _a k, href: ASF::SVN.svnpath!('member_apps', k), target: '_blank'
         end
       end
     end
@@ -85,7 +85,7 @@
         if person.icla && person.icla.claRef
           file = ASF::ICLAFiles.match_claRef(person.icla.claRef.untaint)
           if file
-            _a person.icla.claRef, href: "https://svn.apache.org/repos/private/documents/iclas/#{file}", target: '_blank'
+            _a person.icla.claRef, href: ASF::SVN.svnpath!('iclas', file), target: '_blank'
           else
             _ ''
           end
diff --git a/www/secretary/public-names.cgi b/www/secretary/public-names.cgi
index fd042fc..359dfb2 100755
--- a/www/secretary/public-names.cgi
+++ b/www/secretary/public-names.cgi
@@ -141,7 +141,7 @@
   _h2_!.present! do
     _ 'Present in '
     _a 'iclas.txt', 
-      href: 'https://svn.apache.org/repos/private/foundation/officers/iclas.txt'
+      href: ASF::SVN.svnpath!('officers', 'iclas.txt')
    _ ':'
   end
 
@@ -184,7 +184,7 @@
           _td do
             file = ASF::ICLAFiles.match_claRef(icla.claRef.untaint)
             if file
-              _a icla.claRef, href: "https://svn.apache.org/repos/private/documents/iclas/#{file}"
+              _a icla.claRef, href: ASF::SVN.svnpath!('iclas', file)
             else
               _ icla.claRef || 'unknown'
             end
diff --git a/www/secretary/workbench/Rakefile b/www/secretary/workbench/Rakefile
index 7f887f7..61a32fe 100644
--- a/www/secretary/workbench/Rakefile
+++ b/www/secretary/workbench/Rakefile
@@ -42,7 +42,7 @@
 
 desc 'download mail from whimsy-vm2'
 task 'sync' => '/srv/mail' do
-  sh 'rsync -av --delete whimsy-vm2.apache.org:/srv/mail/ /srv/mail'
+  sh 'rsync -av --delete whimsy.apache.org:/srv/mail/ /srv/mail'
 end
 
 desc 'Fetch and parse latest month only'
diff --git a/www/secretary/workbench/models/attachment.rb b/www/secretary/workbench/models/attachment.rb
index afded69..9daf050 100644
--- a/www/secretary/workbench/models/attachment.rb
+++ b/www/secretary/workbench/models/attachment.rb
@@ -44,6 +44,9 @@
     name.untaint
   end
 
+  # Returns the attachment as an open temporary file
+  # Warning: if the reference count goes to 0, the file may be deleted
+  # so calling code must retain the reference until done.
   def as_file
     file = SafeTempFile.new([safe_name, '.pdf'])
     file.write(body)
diff --git a/www/secretary/workbench/models/message.rb b/www/secretary/workbench/models/message.rb
index 5896f08..49af9ed 100644
--- a/www/secretary/workbench/models/message.rb
+++ b/www/secretary/workbench/models/message.rb
@@ -202,6 +202,33 @@
   end
 
   #
+  # write one or more attachments
+  # returns list of input names with their temporary file pointers
+  # It's not safe to return the path names of the temp files as
+  # that allows the files to be deleted by garbage collection
+  # [[name, open temp file]]
+  def write_att(*attachments)
+    files = []
+
+    # drop all nil and empty values
+    attachments = attachments.flatten.reject {|name| name.to_s.empty?}
+
+    # if last argument is a Hash, treat it as name/value pairs
+    attachments += attachments.pop.to_a if Hash === attachments.last
+
+    if attachments.flatten.length == 1
+      attachment = attachments.first
+      files << [attachment, find(attachment).as_file]
+    else
+      # write out selected attachment
+      attachments.each do |attachment, basename|
+        files << [attachment, find(attachment).as_file]
+      end
+    end
+    files
+  end
+
+  #
   # Construct a reply message, and in the process merge the email
   # address from the original message (from, to, cc) with any additional
   # address provided on the call (to, cc, bcc).  Remove any duplicates
diff --git a/www/secretary/workbench/server.rb b/www/secretary/workbench/server.rb
index 9a4521f..37fab60 100644
--- a/www/secretary/workbench/server.rb
+++ b/www/secretary/workbench/server.rb
@@ -11,6 +11,7 @@
 require 'erb'
 require 'sanitize'
 require 'escape'
+require 'time' # for iso8601
 
 require_relative 'personalize'
 require_relative 'helpers'
@@ -45,6 +46,8 @@
 require 'whimsy/asf/memapps'
 ASF::Mail.configure
 
+SECS_TO_DAYS = 60*60*24
+
 set :show_exceptions, true
 
 disable :logging # suppress log of requests to stderr/error.log
@@ -61,6 +64,21 @@
     @messages = Mailbox.new(@mbox).client_headers.select do |message|
       message[:status] != :deleted
     end
+  else
+    @messages = [] # ensure the array exists
+  end
+
+  # Show outstanding emeritus requests
+  ASF::EmeritusRequestFiles.listnames(true).each do |epoch, file|
+    days = (((Time.now.to_i - epoch.to_i).to_f/SECS_TO_DAYS)).round(1)
+    id = File.basename(file,'.*')
+    @messages << {
+      time: Time.at(epoch.to_i).gmtime.iso8601,
+      href: "/roster/committer/#{id}",
+      from: ASF::Person.find(id).cn,
+      subject: "Pending emeritus request - #{days.to_s} days old",
+      status: days < 10.0 ? :emeritusPending : :emeritusReady
+    }
   end
 
   @cssmtime = File.mtime('public/secmail.css').to_i
@@ -76,7 +94,7 @@
 # support for fetching previous month's worth of messages
 get %r{/(\d{6})} do |mbox|
   @mbox = mbox
-  _json :index
+  _json :index # This invokes workbench/views/index.json.rb
 end
 
 # retrieve a single message
diff --git a/www/secretary/workbench/tasks.rb b/www/secretary/workbench/tasks.rb
index ced5fb2..209c45d 100644
--- a/www/secretary/workbench/tasks.rb
+++ b/www/secretary/workbench/tasks.rb
@@ -77,6 +77,46 @@
     ]
   end
 
+  # Commit new file(s) and update associated index
+  # e.g. add ccla.pdf, ccla.pdf.asc to documents/cclas/xyz/ and update officers/cclas.txt
+  # Parameters:
+  # index_dir - SVN alias of directory containint the index (e.g. foundation or officers)
+  # index_name - name of index file to update (e.g. cclas.txt)
+  # docdir - SVN alias for document directory (e.g. cclas)
+  # docname - document name (as per email)
+  # docsig - document signature (may be null)
+  # outfilename - name of output file (without extension)
+  # outfileext - output file extension
+  # emessage - the email message
+  # svnmessage - the svn commit message
+  # block - the block which is passed the contents of the index file to be updated
+  def svn_multi(index_dir, index_name, docdir, docname, docsig, outfilename, outfileext, emessage, svnmessage, &block)
+    ASF::SVN.multiUpdate_(ASF::SVN.svnpath!(index_dir, index_name), svnmessage, env, _) do |text|
+
+      extras = []
+      # write the attachments as file(s)
+      dest = emessage.write_att(docname, docsig)
+
+      if dest.size > 1 # write to a container directory
+        unless outfilename =~ /\A[a-zA-Z][-.\w]+\z/ # previously done by write_svn
+          raise IOError.new("invalid filename: #{outfilename}")
+        end
+        container = ASF::SVN.svnpath!(docdir, outfilename)
+        extras << ['mkdir', container]
+        dest.each do |name, file|
+          extras << ['put', file.path, File.join(container, name)]
+        end
+      else
+        name, file = dest.flatten
+        extras << ['put', file.path, ASF::SVN.svnpath!(docdir,"#{outfilename}#{outfileext}")]
+      end
+
+      text = yield text # update the index
+
+      [text, extras]
+    end
+  end
+
   def template(name)
     path = File.expand_path("../templates/#{name}", __FILE__.untaint)
     ERB.new(File.read(path.untaint).untaint).result(binding)
diff --git a/www/secretary/workbench/views/actions/ccla.json.rb b/www/secretary/workbench/views/actions/ccla.json.rb
index 54f88b3..d4d4d09 100644
--- a/www/secretary/workbench/views/actions/ccla.json.rb
+++ b/www/secretary/workbench/views/actions/ccla.json.rb
@@ -33,68 +33,43 @@
 @document = "CCLA from #{@company}"
 
 ########################################################################
-#                            document/cclas                            #
+#              document/cclas and cclas.txt                            #
 ########################################################################
 
-# write attachment (+ signature, if present) to the documents/cclas directory
-task "svn commit documents/cclas/#@filename#{fileext}" do
+task "svn commit documents/cclas/#@filename#{fileext} and update cclas.txt" do
+  
+  # construct line to be inserted in cclas.txt
+  @cclalines = "notinavail:" + @company.strip
+  unless @contact.empty?
+    @cclalines += " - #{@contact.strip}"
+  end
+  @cclalines += ":#{@email.strip}:Signed Corp CLA"
+  unless @employees.empty?
+    @cclalines += " for #{@employees.strip.gsub(/\s*\n\s*/, ', ')}"
+  end
+  unless @product.empty?
+    @cclalines += " for #{@product.strip}"
+  end
+
   form do
     _input value: @selected, name: 'selected'
 
     if @signature and not @signature.empty?
       _input value: @signature, name: 'signature'
     end
-  end
 
-  complete do |dir|
-    # checkout empty directory
-    svn 'checkout', '--depth', 'empty',
-      ASF::SVN.svnurl('cclas'), "#{dir}/cclas"
-
-    # create/add file(s)
-    dest = message.write_svn("#{dir}/cclas", @filename, @selected, @signature)
-
-    # Show files to be added
-    svn 'status', "#{dir}/cclas"
-
-    # commit changes
-    svn 'commit', "#{dir}/cclas/#{@filename}#{fileext}", '-m', @document
-  end
-end
-
-########################################################################
-#                          officers/cclas.txt                          #
-########################################################################
-
-# insert line into cclas.txt
-task "svn commit foundation/officers/cclas.txt" do
-  # construct line to be inserted
-  @cclalines = "notinavail:" + @company.strip
-
-  unless @contact.empty?
-    @cclalines += " - #{@contact.strip}"
-  end
-
-  @cclalines += ":#{@email.strip}:Signed Corp CLA"
-
-  unless @employees.empty?
-    @cclalines += " for #{@employees.strip.gsub(/\s*\n\s*/, ', ')}"
-  end
-
-  unless @product.empty?
-    @cclalines += " for #{@product.strip}"
-  end
-
-  form do
     _input value: @cclalines, name: 'cclalines'
   end
 
   complete do |dir|
-    path = ASF::SVN.svnpath!('officers', 'cclas.txt')
-    ASF::SVN.update(path, @document, env, _, {diff: true}) do |tmpdir, contents|
-      contents + @cclalines + "\n"
+
+    svn_multi('officers', 'cclas.txt', 'cclas', @selected, @signature, @filename, fileext, message, @document) do |input|
+      # append entry to cclas.txt
+      input + @cclalines + "\n"
     end
+
   end
+
 end
 
 ########################################################################
diff --git a/www/secretary/workbench/views/actions/check-mail.json.rb b/www/secretary/workbench/views/actions/check-mail.json.rb
index 853afa1..fa2bbf6 100644
--- a/www/secretary/workbench/views/actions/check-mail.json.rb
+++ b/www/secretary/workbench/views/actions/check-mail.json.rb
@@ -1,3 +1,5 @@
+# This code is invoked from workbench/views/index.js.rb
+
 Mailbox.fetch @mbox
 
 mbox = Mailbox.new(@mbox)
diff --git a/www/secretary/workbench/views/actions/grant.json.rb b/www/secretary/workbench/views/actions/grant.json.rb
index 9e5d54c..33aa99d 100644
--- a/www/secretary/workbench/views/actions/grant.json.rb
+++ b/www/secretary/workbench/views/actions/grant.json.rb
@@ -33,71 +33,36 @@
 @document = "Software Grant from #{@company}"
 
 ########################################################################
-#                           document/grants                            #
+#             document/grants & officers/grants.txt                    #
 ########################################################################
 
 # write attachment (+ signature, if present) to the documents/grants directory
-task "svn commit documents/grants/#@filename#{fileext}" do
-  form do
-    _input value: @selected, name: 'selected'
+task "svn commit documents/grants/#@filename#{fileext} and update grants.txt" do
 
-    if @signature and not @signature.empty?
-      _input value: @signature, name: 'signature'
-    end
-  end
-
-  complete do |dir|
-    # checkout empty directory
-    svn 'checkout', '--depth', 'empty',
-      ASF::SVN.svnurl('grants'), "#{dir}/grants"
-
-    # create/add file(s)
-    dest = message.write_svn("#{dir}/grants", @filename, @selected, @signature)
-
-    # Show files to be added
-    svn 'status', "#{dir}/grants"
-
-    # commit changes
-    svn 'commit', "#{dir}/grants/#{@filename}#{fileext}", '-m', @document
-  end
-end
-
-########################################################################
-#                         officers/grants.txt                          #
-########################################################################
-
-# insert line into grants.txt
-task "svn commit foundation/officers/grants.txt" do
   # construct line to be inserted
   @grantlines = "#{@company.strip}" +
     "\n  file: #{@filename}#{fileext}" +
     "\n  for: #{@description.strip.gsub(/\r?\n\s*/,"\n       ")}"
 
   form do
+    _input value: @selected, name: 'selected'
+
+    if @signature and not @signature.empty?
+      _input value: @signature, name: 'signature'
+    end
+
     _textarea @grantlines, name: 'grantlines', 
       rows: @grantlines.split("\n").length
   end
 
   complete do |dir|
-    # checkout empty officers directory
-    svn 'checkout', '--depth', 'empty',
-      ASF::SVN.svnurl!('officers'), 
-      File.join(dir, 'officers')
 
-    # retrieve grants.txt
-    dest = File.join(dir, 'officers', 'grants.txt')
-    svn 'update', dest
+    svn_multi('officers', 'grants.txt', 'grants', @selected, @signature, @filename, fileext, message, @document) do |input|
+      # update grants.txt
+      marker = "\n# registering.  documents on way to Secretary.\n"
+      input.split(marker).insert(1, "\n#{@grantlines}\n", marker).join
+    end
 
-    # update grants.txt
-    marker = "\n# registering.  documents on way to Secretary.\n"
-    File.write dest,
-      File.read(dest).split(marker).insert(1, "\n#{@grantlines}\n", marker).join
-
-    # show the changes
-    svn 'diff', dest
-
-    # commit changes
-    svn 'commit', dest, '-m', @document
   end
 end
 
diff --git a/www/secretary/workbench/views/actions/icla.json.rb b/www/secretary/workbench/views/actions/icla.json.rb
index c376241..69d69e9 100644
--- a/www/secretary/workbench/views/actions/icla.json.rb
+++ b/www/secretary/workbench/views/actions/icla.json.rb
@@ -224,7 +224,7 @@
     complete do |dir|
       # checkout acreq directory
       svn 'checkout', '--depth', 'files',
-        'https://svn.apache.org/repos/infra/infrastructure/trunk/acreq', 
+      ASF::SVN.svnurl!('acreq'),
         "#{dir}/acreq"
 
       # update new-account-reqs.txt
diff --git a/www/secretary/workbench/views/actions/memapp.json.rb b/www/secretary/workbench/views/actions/memapp.json.rb
index 06051f1..d9e7a18 100644
--- a/www/secretary/workbench/views/actions/memapp.json.rb
+++ b/www/secretary/workbench/views/actions/memapp.json.rb
@@ -31,43 +31,12 @@
 
 ########################################################################
 #                         document/member_apps                         # 
+#                             members.txt                              #
 ########################################################################
 
 # write attachment (+ signature, if present) to the documents/member_apps
 # directory
-task "svn commit documents/member_apps/#@filename#{fileext}" do
-  form do
-    _input value: @selected, name: 'selected'
-
-    if @signature and not @signature.empty?
-      _input value: @signature, name: 'signature'
-    end
-  end
-
-  complete do |dir|
-    # checkout empty directory
-    svn 'checkout', '--depth', 'empty',
-      ASF::SVN.svnurl('member_apps'),
-      "#{dir}/member_apps"
-
-    # create/add file(s)
-    dest = message.write_svn("#{dir}/member_apps", @filename, @selected,
-      @signature)
-
-    # Show files to be added
-    svn 'status', "#{dir}/member_apps"
-
-    # commit changes
-    svn 'commit', "#{dir}/member_apps/#{@filename}#{fileext}", '-m', @document
-  end
-end
-
-########################################################################
-#                             members.txt                              #
-########################################################################
-
-# insert entry into members.txt
-task "svn commit foundation/members.txt" do
+task "svn commit documents/member_apps/#@filename#{fileext} and update members.txt" do
   # Construct initial entry:
   fields = {
     fullname: @fullname,
@@ -81,34 +50,31 @@
   @entry = ASF::Member.make_entry(fields)
 
   form do
+    _input value: @selected, name: 'selected'
+
+    if @signature and not @signature.empty?
+      _input value: @signature, name: 'signature'
+    end
+
     _textarea @entry, name: 'entry', rows: @entry.split("\n").length
   end
 
   complete do |dir|
-    # checkout empty foundation directory
-    svn 'checkout', '--depth', 'empty',
-      ASF::SVN.svnurl!('foundation'), File.join(dir, 'foundation')
 
-    # retrieve members.txt
-    dest = "#{dir}/foundation/members.txt"
-    svn 'update', dest
+    svn_multi('foundation', 'members.txt', 'member_apps', @selected, @signature, @filename, fileext, message, @document) do |members_txt|
 
     # update members.txt
+    # TODO this should be a library method
     pattern = /^Active.*?^=+\n+(.*?)^Emeritus/m
-    members_txt = open(dest).read
     data = members_txt.scan(pattern).flatten.first
     members = data.split(/^\s+\*\)\s+/)
     members.shift
     members.push @entry
     members_txt[pattern,1] = " *) " + members.join("\n *) ")
     members_txt[/We now number (\d+) active members\./,1] = members.length.to_s
-    File.write(dest, ASF::Member.sort(members_txt))
+    ASF::Member.sort(members_txt)
+  end
 
-    # show the changes
-    svn 'diff', dest
-
-    # commit changes
-    svn 'commit', dest, '-m', @document
   end
 end
 
@@ -151,7 +117,7 @@
 task "subscribe to members@apache.org" do
   user = ASF::Person.find(@availid)
   vars = {
-    version: 3, # This must match http://s.apache.org/008
+    version: 3, # This must match committers/subscribe.cgi#FORMAT_NUMBER
     availid: @availid,
     addr: @email,
     listkey: 'members',
@@ -171,7 +137,7 @@
 
     # checkout empty directory
     svn 'checkout', '--depth', 'empty',
-      "https://svn.apache.org/repos/infra/infrastructure/trunk/subreq",
+      ASF::SVN.svnpath!('subreq'),
       "#{dir}/subreq"
 
     # write out subscription request
@@ -190,6 +156,8 @@
 #                      update memapp-received.txt                      #
 ########################################################################
 
+# TODO combine with other SVN updates
+
 task "svn commit memapp-received.text" do
   meetings = ASF::SVN['Meetings']
   file = Dir["#{meetings}/2*/memapp-received.txt"].sort.last.untaint
diff --git a/www/secretary/workbench/views/check-signature.js.rb b/www/secretary/workbench/views/check-signature.js.rb
index 0aba657..162c1a1 100644
--- a/www/secretary/workbench/views/check-signature.js.rb
+++ b/www/secretary/workbench/views/check-signature.js.rb
@@ -15,7 +15,7 @@
   end
 
   def mounted()
-    @signature = CheckSignature.find(@@selected, @@attachments)
+    @signature = CheckSignature.find(decodeURIComponent(@@selected), @@attachments)
 
     if @signature and @signature != @checked
       @flag = 'alert-info'
diff --git a/www/secretary/workbench/views/forms/ccla.js.rb b/www/secretary/workbench/views/forms/ccla.js.rb
index 4e14066..f626bdc 100644
--- a/www/secretary/workbench/views/forms/ccla.js.rb
+++ b/www/secretary/workbench/views/forms/ccla.js.rb
@@ -118,7 +118,7 @@
     # wire up form
     jQuery('form')[0].addEventListener('submit', self.file)
     jQuery('input[name=message]').val(window.parent.location.pathname)
-    jQuery('input[name=selected]').val(decodeURI(@@selected))
+    jQuery('input[name=selected]').val(decodeURIComponent(@@selected))
 
     # Safari autocomplete workaround: trigger change on leaving field
     # https://github.com/facebook/react/issues/2125
diff --git a/www/secretary/workbench/views/forms/emeritus-request.js.rb b/www/secretary/workbench/views/forms/emeritus-request.js.rb
index c4111b0..2313c09 100644
--- a/www/secretary/workbench/views/forms/emeritus-request.js.rb
+++ b/www/secretary/workbench/views/forms/emeritus-request.js.rb
@@ -10,7 +10,7 @@
   end
 
   def mounted
-    jQuery('input[name=selected]').val(decodeURI(@@selected))
+    jQuery('input[name=selected]').val(decodeURIComponent(@@selected))
     jQuery('input[name=message]').val(window.parent.location.pathname)
     if not @members.empty?
       @disabled = false
diff --git a/www/secretary/workbench/views/forms/grant.js.rb b/www/secretary/workbench/views/forms/grant.js.rb
index e1a35dd..8e8d385 100644
--- a/www/secretary/workbench/views/forms/grant.js.rb
+++ b/www/secretary/workbench/views/forms/grant.js.rb
@@ -110,7 +110,7 @@
     # wire up form
     jQuery('form')[0].addEventListener('submit', self.file)
     jQuery('input[name=message]').val(window.parent.location.pathname)
-    jQuery('input[name=selected]').val(decodeURI(@@selected))
+    jQuery('input[name=selected]').val(decodeURIComponent(@@selected))
 
     # Safari autocomplete workaround: trigger change on leaving field
     # https://github.com/facebook/react/issues/2125
diff --git a/www/secretary/workbench/views/forms/icla.js.rb b/www/secretary/workbench/views/forms/icla.js.rb
index 1d7d6eb..334faf7 100644
--- a/www/secretary/workbench/views/forms/icla.js.rb
+++ b/www/secretary/workbench/views/forms/icla.js.rb
@@ -191,7 +191,7 @@
     # wire up form
     jQuery('form')[0].addEventListener('submit', self.file)
     jQuery('input[name=message]').val(window.parent.location.pathname)
-    jQuery('input[name=selected]').val(@@selected)
+    jQuery('input[name=selected]').val(decodeURIComponent(@@selected))
 
     # Safari autocomplete workaround: trigger change on leaving field
     # https://github.com/facebook/react/issues/2125
diff --git a/www/secretary/workbench/views/forms/icla2.js.rb b/www/secretary/workbench/views/forms/icla2.js.rb
index bb43a9c..ead965c 100644
--- a/www/secretary/workbench/views/forms/icla2.js.rb
+++ b/www/secretary/workbench/views/forms/icla2.js.rb
@@ -159,7 +159,7 @@
     # wire up form
     jQuery('form')[0].addEventListener('submit', self.file)
     jQuery('input[name=message]').val(window.parent.location.pathname)
-    jQuery('input[name=selected]').val(decodeURI(@@selected))
+    jQuery('input[name=selected]').val(decodeURIComponent(@@selected))
 
     # Safari autocomplete workaround: trigger change on leaving field
     # https://github.com/facebook/react/issues/2125
diff --git a/www/secretary/workbench/views/index.js.rb b/www/secretary/workbench/views/index.js.rb
index c4ff935..fe1abe1 100644
--- a/www/secretary/workbench/views/index.js.rb
+++ b/www/secretary/workbench/views/index.js.rb
@@ -47,7 +47,11 @@
 
             _tr row_options do
               _td do
-                _a time, href: "#{message.href}", title: message.time
+                if[:emeritusReady, :emeritusPending].include? message.status
+                  _a time, href: "#{message.href}", title: message.time, target: "_blank"
+                else
+                  _a time, href: "#{message.href}", title: message.time
+                end
               end 
               _td message.from
               _td message.subject
diff --git a/www/secretary/workbench/views/index.json.rb b/www/secretary/workbench/views/index.json.rb
index c685b42..2df5801 100644
--- a/www/secretary/workbench/views/index.json.rb
+++ b/www/secretary/workbench/views/index.json.rb
@@ -1,4 +1,5 @@
 # find indicated mailbox in the list of available mailboxes
+# This code is invoked by workbench/server.rb
 available = Dir["#{ARCHIVE}/*.yml"].sort
 index = available.find_index "#{ARCHIVE}/#{@mbox}.yml"
 
diff --git a/www/secretary/workbench/views/parts.js.rb b/www/secretary/workbench/views/parts.js.rb
index 1652c18..3eb40cb 100644
--- a/www/secretary/workbench/views/parts.js.rb
+++ b/www/secretary/workbench/views/parts.js.rb
@@ -35,7 +35,7 @@
     }
 
     # locate corresponding signature file (if any)
-    signature = CheckSignature.find(@selected, @attachments)
+    signature = CheckSignature.find(decodeURIComponent(@selected), @attachments)
 
     # list of attachments
     _ul.attachments! @attachments, ref: 'attachments' do |attachment|
@@ -393,7 +393,7 @@
     HTTP.post('../../actions/delete-attachment', data).then {|response|
       @attachments = response.attachments
       if event.type == 'message'
-        signature = CheckSignature.find(@selected, response.attachments)
+        signature = CheckSignature.find(decodeURIComponent(@selected), response.attachments)
         @busy = false
         @selected = signature
         self.delete_attachment(event) if signature
diff --git a/www/status/svn.cgi b/www/status/svn.cgi
index 9be7b48..6de40fe 100755
--- a/www/status/svn.cgi
+++ b/www/status/svn.cgi
@@ -42,7 +42,7 @@
     end
 
     _tbody do
-      repository.values.sort_by {|value| value['url']}.each do |svn|
+      repository.sort_by {|name, value| value['url']}.each do |name, svn|
         local = ASF::SVN.find(svn['url']) unless svn['url'] =~ /^https?:/
 
         color = nil
@@ -56,7 +56,7 @@
 
         _tr_ class: color do
           _td! title: local do
-            _a svn['url'], href: "https://svn.apache.org/repos/#{svn['url']}"
+            _a svn['url'], href: ASF::SVN.svnpath!(name)
           end
 
           _td local
@@ -170,7 +170,7 @@
 
       repository_url = @name
       unless repository_url =~ /^https?:/
-        repository_url = "https://svn.apache.org/repos/#{repository_url}"
+        repository_url = ASF::SVN.svnpath!(repository_url)
       end
 
       log = `svn checkout #{repository_url.untaint} #{local_path.untaint} 2>&1`