1# Copyright (C) 2018 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Send notification email if new version is found.
15
16Example usage:
17external_updater_notifier \
18    --history ~/updater/history \
19    --generate_change \
20    --recipients xxx@xxx.xxx \
21    googletest
22"""
23
24from datetime import timedelta, datetime
25import argparse
26import json
27import os
28import re
29import subprocess
30import time
31
32# pylint: disable=invalid-name
33
34def parse_args():
35    """Parses commandline arguments."""
36
37    parser = argparse.ArgumentParser(
38        description='Check updates for third party projects in external/.')
39    parser.add_argument('--history',
40                        help='Path of history file. If doesn'
41                        't exist, a new one will be created.')
42    parser.add_argument(
43        '--recipients',
44        help='Comma separated recipients of notification email.')
45    parser.add_argument(
46        '--generate_change',
47        help='If set, an upgrade change will be uploaded to Gerrit.',
48        action='store_true',
49        required=False)
50    parser.add_argument('paths', nargs='*', help='Paths of the project.')
51    parser.add_argument('--all',
52                        action='store_true',
53                        help='Checks all projects.')
54
55    return parser.parse_args()
56
57
58def _get_android_top():
59    return os.environ['ANDROID_BUILD_TOP']
60
61
62CHANGE_URL_PATTERN = r'(https:\/\/[^\s]*android-review[^\s]*) Upgrade'
63CHANGE_URL_RE = re.compile(CHANGE_URL_PATTERN)
64
65
66def _read_owner_file(proj):
67    owner_file = os.path.join(_get_android_top(), 'external', proj, 'OWNERS')
68    if not os.path.isfile(owner_file):
69        return None
70    with open(owner_file, 'r', encoding='utf-8') as f:
71        return f.read().strip()
72
73
74def _send_email(proj, latest_ver, recipient, upgrade_log):
75    print(f'Sending email for {proj}: {latest_ver}')
76    msg = ""
77    match = CHANGE_URL_RE.search(upgrade_log)
78    if match is not None:
79        subject = "[Succeeded]"
80        msg = f'An upgrade change is generated at:\n{match.group(1)}'
81    else:
82        subject = "[Failed]"
83        msg = 'Failed to generate upgrade change. See logs below for details.'
84
85    subject += f" {proj} {latest_ver}"
86    owners = _read_owner_file(proj)
87    if owners:
88        msg += '\n\nOWNERS file: \n'
89        msg += owners
90
91    msg += '\n\n'
92    msg += upgrade_log
93
94    cc_recipient = ''
95    for line in owners.splitlines():
96        line = line.strip()
97        if line.endswith('@google.com'):
98            cc_recipient += line
99            cc_recipient += ','
100
101    subprocess.run(['sendgmr',
102                    f'--to={recipient}',
103                    f'--cc={cc_recipient}',
104                    f'--subject={subject}'],
105                   check=True,
106                   stdout=subprocess.PIPE,
107                   stderr=subprocess.PIPE,
108                   input=msg,
109                   encoding='ascii')
110
111
112COMMIT_PATTERN = r'^[a-f0-9]{40}$'
113COMMIT_RE = re.compile(COMMIT_PATTERN)
114
115
116def is_commit(commit: str) -> bool:
117    """Whether a string looks like a SHA1 hash."""
118    return bool(COMMIT_RE.match(commit))
119
120
121NOTIFIED_TIME_KEY_NAME = 'latest_notified_time'
122
123
124def _should_notify(latest_ver, proj_history):
125    if latest_ver in proj_history:
126        # Processed this version before.
127        return False
128
129    timestamp = proj_history.get(NOTIFIED_TIME_KEY_NAME, 0)
130    time_diff = datetime.today() - datetime.fromtimestamp(timestamp)
131    if is_commit(latest_ver) and time_diff <= timedelta(days=30):
132        return False
133
134    return True
135
136
137def _process_results(args, history, results):
138    for proj, res in results.items():
139        if 'latest' not in res:
140            continue
141        latest_ver = res['latest']
142        current_ver = res['current']
143        if latest_ver == current_ver:
144            continue
145        proj_history = history.setdefault(proj, {})
146        if _should_notify(latest_ver, proj_history):
147            upgrade_log = _upgrade(proj) if args.generate_change else ""
148            try:
149                _send_email(proj, latest_ver, args.recipients, upgrade_log)
150                proj_history[latest_ver] = int(time.time())
151                proj_history[NOTIFIED_TIME_KEY_NAME] = int(time.time())
152            except subprocess.CalledProcessError as err:
153                msg = f"""Failed to send email for {proj} ({latest_ver}).
154stdout: {err.stdout}
155stderr: {err.stderr}"""
156                print(msg)
157
158
159RESULT_FILE_PATH = '/tmp/update_check_result.json'
160
161
162def send_notification(args):
163    """Compare results and send notification."""
164    results = {}
165    with open(RESULT_FILE_PATH, 'r', encoding='utf-8') as f:
166        results = json.load(f)
167    history = {}
168    try:
169        with open(args.history, 'r', encoding='utf-8') as f:
170            history = json.load(f)
171    except (FileNotFoundError, json.decoder.JSONDecodeError):
172        pass
173
174    _process_results(args, history, results)
175
176    with open(args.history, 'w', encoding='utf-8') as f:
177        json.dump(history, f, sort_keys=True, indent=4)
178
179
180def _upgrade(proj):
181    # pylint: disable=subprocess-run-check
182    out = subprocess.run([
183        'out/soong/host/linux-x86/bin/external_updater', 'update', proj
184    ],
185                         stdout=subprocess.PIPE,
186                         stderr=subprocess.PIPE,
187                         cwd=_get_android_top())
188    stdout = out.stdout.decode('utf-8')
189    stderr = out.stderr.decode('utf-8')
190    return f"""
191====================
192|    Debug Info    |
193====================
194-=-=-=-=stdout=-=-=-=-
195{stdout}
196
197-=-=-=-=stderr=-=-=-=-
198{stderr}
199"""
200
201
202def _check_updates(args):
203    params = [
204        'out/soong/host/linux-x86/bin/external_updater', 'check',
205        '--json_output', RESULT_FILE_PATH, '--delay', '30'
206    ]
207    if args.all:
208        params.append('--all')
209    else:
210        params += args.paths
211
212    print(_get_android_top())
213    # pylint: disable=subprocess-run-check
214    subprocess.run(params, cwd=_get_android_top())
215
216
217def main():
218    """The main entry."""
219
220    args = parse_args()
221    _check_updates(args)
222    send_notification(args)
223
224
225if __name__ == '__main__':
226    main()
227