Repository: freedomofpress/securedrop

Issue:
Update localization management guide for recent changes to string freeze
## Description

We combined string and feature freeze, so we need to update the documentation here: https://docs.securedrop.org/en/release-0.12.2/development/i18n.html#release-management

We should combine the tasks in the two sections, as well as remove the task "Backport every commit changing a source string to the release branch"



Discussion:
Also the link for "Go to the Weblate commit page for SecureDrop" in the docs should be 
https://weblate.securedrop.org/projects/securedrop/securedrop/#information

Resolving patch:
diff --git a/securedrop/i18n_tool.py b/securedrop/i18n_tool.py
--- a/securedrop/i18n_tool.py
+++ b/securedrop/i18n_tool.py
@@ -1,6 +1,7 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
 
+from __future__ import print_function
 import argparse
 import io
 import six
@@ -201,7 +202,7 @@ def require_git_email_name(git_dir):
                'git -C {d} config --get user.email > /dev/null'.format(
                    d=git_dir))
         if subprocess.call(cmd, shell=True):  # nosec
-            if six.u('docker') in io.open('/proc/1/cgroup').read():
+            if u'docker' in io.open('/proc/1/cgroup').read():
                 log.error("remember ~/.gitconfig does not exist "
                           "in the dev-shell Docker container, "
                           "only .git/config does")
@@ -209,7 +210,7 @@ def require_git_email_name(git_dir):
         return True
 
     def update_docs(self, args):
-        l10n_content = six.u('.. GENERATED BY i18n_tool.py DO NOT EDIT:\n\n')
+        l10n_content = u'.. GENERATED BY i18n_tool.py DO NOT EDIT:\n\n'
         for (code, info) in sorted(I18NTool.SUPPORTED_LANGUAGES.items()):
             l10n_content += '* ' + info['name'] + ' (``' + code + '``)\n'
         includes = join(args.documentation_dir, 'includes')
@@ -243,7 +244,7 @@ def update_from_weblate(self, args):
         codes = list(I18NTool.SUPPORTED_LANGUAGES.keys())
         if args.supported_languages:
             codes = args.supported_languages.split(',')
-        for code in codes:
+        for code in sorted(codes):
             info = I18NTool.SUPPORTED_LANGUAGES[code]
 
             def need_update(p):
@@ -282,40 +283,70 @@ def add(p):
             if updated:
                 self.upstream_commit(args, code)
 
+    def translators(self, args, path, range):
+        """
+        Return the set of people who've modified a file in Weblate.
+
+        Extracts all the authors of translation changes to the given
+        path in the given range. Translation changes are identified by
+        the presence of "Translated using Weblate" in the commit
+        message.
+        """
+        translation_re = re.compile('Translated using Weblate')
+
+        path_changes = git(
+            '--no-pager', '-C', args.root,
+            'log', '--format=%aN\x1e%s', range, '--', path,
+            _encoding='utf-8'
+        )
+        path_changes = u"{}".format(path_changes)
+        path_changes = [c.split('\x1e') for c in path_changes.strip().split('\n')]
+        path_changes = [c for c in path_changes if len(c) > 1 and translation_re.match(c[1])]
+
+        path_authors = [c[0] for c in path_changes]
+        return set(path_authors)
+
     def upstream_commit(self, args, code):
         self.require_git_email_name(args.root)
         authors = set()
-        diffs = six.text_type(git('--no-pager', '-C', args.root,
-                                  'diff', '--name-only', '--cached').stdout)
-        for path in diffs.strip().split('\n'):
-            previous_message = str(git(
+        diffs = u"{}".format(git('--no-pager', '-C', args.root, 'diff', '--name-only', '--cached'))
+
+        # This change will need to be checked in the next translation
+        # cycle after 0.13.0, to ensure that only recent commits are
+        # being picked up here.
+        update_re = re.compile(r'(?:copied from|  revision:) (\w+)')
+
+        for path in sorted(diffs.strip().split('\n')):
+            previous_message = u"{}".format(git(
                 '--no-pager', '-C', args.root, 'log', '-n', '1', path,
                 _encoding='utf-8'))
-            m = re.search('copied from (\w+)', previous_message)
+            m = update_re.search(previous_message)
             if m:
                 origin = m.group(1)
             else:
                 origin = ''
-            git_authors = str(git(
-                '--no-pager', '-C', args.root, 'log', '--format=%aN',
-                '{}..i18n/i18n'.format(origin), '--',
-                path, _encoding='utf-8'))
-            git_authors = git_authors.strip().split('\n')
-            authors |= set(git_authors)
-        current = git('-C', args.root, 'rev-parse', 'i18n/i18n').stdout
+            authors |= self.translators(args, path, '{}..i18n/i18n'.format(origin))
+
+        authors = u"\n  ".join(sorted(authors))
+
+        current = git('-C', args.root, 'rev-parse', 'i18n/i18n')
         info = I18NTool.SUPPORTED_LANGUAGES[code]
-        message = textwrap.dedent(six.u("""
-        l10n: updated {code} {name}
-
-        localizers: {authors}
-
-        {remote}
-        copied from {current}
-        """.format(remote=args.url,
-                   name=info['name'],
-                   authors=", ".join(authors),
-                   code=code,
-                   current=current)))
+        message = textwrap.dedent(u"""
+        l10n: updated {name} ({code})
+
+        contributors:
+          {authors}
+
+        updated from:
+          repo: {remote}
+          revision: {current}
+        """).format(
+            remote=args.url,
+            name=info['name'],
+            authors=authors,
+            code=code,
+            current=current
+        )
         git('-C', args.root, 'commit', '-m', message)
 
     def set_update_from_weblate_parser(self, subps):
@@ -338,6 +369,62 @@ def set_update_from_weblate_parser(self, subps):
             help='comma separated list of supported languages')
         parser.set_defaults(func=self.update_from_weblate)
 
+    def set_list_translators_parser(self, subps):
+        parser = subps.add_parser('list-translators',
+                                  help=('List contributing translators'))
+        root = join(dirname(realpath(__file__)), '..')
+        parser.add_argument(
+            '--root',
+            default=root,
+            help=('root of the SecureDrop git repository'
+                  ' (default {})'.format(root)))
+        url = 'https://github.com/freedomofpress/securedrop-i18n'
+        parser.add_argument(
+            '--url',
+            default=url,
+            help=('URL of the weblate repository'
+                  ' (default {})'.format(url)))
+        parser.add_argument(
+            '--all',
+            action="store_true",
+            help="List everyone who's ever contributed."
+        )
+        parser.set_defaults(func=self.list_translators)
+
+    def list_translators(self, args):
+        self.ensure_i18n_remote(args)
+        codes = list(I18NTool.SUPPORTED_LANGUAGES.keys())
+        path_templates = [
+            "install_files/ansible-base/roles/tails-config/templates/{}.po",
+            "securedrop/translations/{}/LC_MESSAGES/messages.po",
+        ]
+        update_re = re.compile(r'(?:copied from|  revision:) (\w+)')
+        for code in sorted(codes):
+            translators = set([])
+            info = I18NTool.SUPPORTED_LANGUAGES[code]
+            paths = [os.path.join(args.root, t.format(code)) for t in path_templates]
+            for path in paths:
+                if not os.path.exists(path):
+                    print("Skipping non-existent .po file: {}".format(path), file=sys.stderr)
+                    continue
+                try:
+                    range = "i18n/i18n"
+                    if not args.all:
+                        previous_message = u"{}".format(git(
+                            '--no-pager', '-C', args.root, 'log', '-n', '1', path,
+                            _encoding='utf-8'))
+                        m = update_re.search(previous_message)
+                        if m:
+                            origin = m.group(1)
+                        else:
+                            origin = ''
+                        range = '{}..i18n/i18n'.format(origin)
+                    t = self.translators(args, path, range)
+                    translators.update(t)
+                except Exception as e:
+                    print("Could not check git history of {}: {}".format(path, e), file=sys.stderr)
+            print(u"{} ({}):\n  {}".format(code, info["name"], "\n  ".join(sorted(translators))))
+
     def get_args(self):
         parser = argparse.ArgumentParser(
             prog=__file__,
@@ -349,6 +436,7 @@ def get_args(self):
         self.set_translate_desktop_parser(subps)
         self.set_update_docs_parser(subps)
         self.set_update_from_weblate_parser(subps)
+        self.set_list_translators_parser(subps)
 
         return parser
 


Test patch:
diff --git a/securedrop/tests/test_i18n_tool.py b/securedrop/tests/test_i18n_tool.py
--- a/securedrop/tests/test_i18n_tool.py
+++ b/securedrop/tests/test_i18n_tool.py
@@ -284,8 +284,8 @@ def r():
             '--url', join(str(tmpdir), 'i18n'),
             '--supported-languages', 'nl',
         ])
-        assert 'l10n: updated nl' in r()
-        assert 'l10n: updated de_DE' not in r()
+        assert 'l10n: updated Dutch (nl)' in r()
+        assert 'l10n: updated German (de_DE)' not in r()
 
         #
         # de_DE is added but there is no change in the nl translation
@@ -299,8 +299,8 @@ def r():
             '--url', join(str(tmpdir), 'i18n'),
             '--supported-languages', 'nl,de_DE',
         ])
-        assert 'l10n: updated nl' not in r()
-        assert 'l10n: updated de_DE' in r()
+        assert 'l10n: updated Dutch (nl)' not in r()
+        assert 'l10n: updated German (de_DE)' in r()
 
         #
         # nothing new for nl or de_DE: nothing is done
@@ -313,8 +313,8 @@ def r():
             '--url', join(str(tmpdir), 'i18n'),
             '--supported-languages', 'nl,de_DE',
         ])
-        assert 'l10n: updated nl' not in r()
-        assert 'l10n: updated de_DE' not in r()
+        assert 'l10n: updated Dutch (nl)' not in r()
+        assert 'l10n: updated German (de_DE)' not in r()
         message = six.text_type(git('--no-pager', '-C', 'securedrop', 'show',
                                     _cwd=d, _encoding='utf-8'))
         assert six.u("Loïc") in message
@@ -346,8 +346,8 @@ def r():
             '--url', join(str(tmpdir), 'i18n'),
             '--supported-languages', 'nl,de_DE',
         ])
-        assert 'l10n: updated nl' in r()
-        assert 'l10n: updated de_DE' not in r()
+        assert 'l10n: updated Dutch (nl)' in r()
+        assert 'l10n: updated German (de_DE)' not in r()
         message = six.text_type(git('--no-pager', '-C', 'securedrop', 'show',
                                     _cwd=d))
         assert "Someone Else" in message