1# Copyright 2018 The gRPC Authors
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
15from __future__ import print_function
16
17import datetime
18import json
19import os
20import sys
21import time
22import traceback
23
24import jwt
25import requests
26
27_GITHUB_API_PREFIX = 'https://api.github.com'
28_GITHUB_REPO = 'grpc/grpc'
29_GITHUB_APP_ID = 22338
30_INSTALLATION_ID = 519109
31
32_ACCESS_TOKEN_CACHE = None
33_ACCESS_TOKEN_FETCH_RETRIES = 6
34_ACCESS_TOKEN_FETCH_RETRIES_INTERVAL_S = 15
35
36_CHANGE_LABELS = {
37    -1: 'improvement',
38    0: 'none',
39    1: 'low',
40    2: 'medium',
41    3: 'high',
42}
43
44_INCREASE_DECREASE = {
45    -1: 'decrease',
46    0: 'neutral',
47    1: 'increase',
48}
49
50
51def _jwt_token():
52    github_app_key = open(
53        os.path.join(os.environ['KOKORO_KEYSTORE_DIR'],
54                     '73836_grpc_checks_private_key'), 'rb').read()
55    return jwt.encode(
56        {
57            'iat': int(time.time()),
58            'exp': int(time.time() + 60 * 10),  # expire in 10 minutes
59            'iss': _GITHUB_APP_ID,
60        },
61        github_app_key,
62        algorithm='RS256')
63
64
65def _access_token():
66    global _ACCESS_TOKEN_CACHE
67    if _ACCESS_TOKEN_CACHE == None or _ACCESS_TOKEN_CACHE['exp'] < time.time():
68        for i in range(_ACCESS_TOKEN_FETCH_RETRIES):
69            resp = requests.post(
70                url='https://api.github.com/app/installations/%s/access_tokens'
71                % _INSTALLATION_ID,
72                headers={
73                    'Authorization': 'Bearer %s' % _jwt_token(),
74                    'Accept': 'application/vnd.github.machine-man-preview+json',
75                })
76
77            try:
78                _ACCESS_TOKEN_CACHE = {
79                    'token': resp.json()['token'],
80                    'exp': time.time() + 60
81                }
82                break
83            except (KeyError, ValueError):
84                traceback.print_exc()
85                print('HTTP Status %d %s' % (resp.status_code, resp.reason))
86                print("Fetch access token from Github API failed:")
87                print(resp.text)
88                if i != _ACCESS_TOKEN_FETCH_RETRIES - 1:
89                    print('Retrying after %.2f second.' %
90                          _ACCESS_TOKEN_FETCH_RETRIES_INTERVAL_S)
91                    time.sleep(_ACCESS_TOKEN_FETCH_RETRIES_INTERVAL_S)
92        else:
93            print("error: Unable to fetch access token, exiting...")
94            sys.exit(0)
95
96    return _ACCESS_TOKEN_CACHE['token']
97
98
99def _call(url, method='GET', json=None):
100    if not url.startswith('https://'):
101        url = _GITHUB_API_PREFIX + url
102    headers = {
103        'Authorization': 'Bearer %s' % _access_token(),
104        'Accept': 'application/vnd.github.antiope-preview+json',
105    }
106    return requests.request(method=method, url=url, headers=headers, json=json)
107
108
109def _latest_commit():
110    resp = _call(
111        '/repos/%s/pulls/%s/commits' %
112        (_GITHUB_REPO, os.environ['KOKORO_GITHUB_PULL_REQUEST_NUMBER']))
113    return resp.json()[-1]
114
115
116def check_on_pr(name, summary, success=True):
117    """Create/Update a check on current pull request.
118
119    The check runs are aggregated by their name, so newer check will update the
120    older check with the same name.
121
122    Requires environment variable 'KOKORO_GITHUB_PULL_REQUEST_NUMBER' to indicate which pull request
123    should be updated.
124
125    Args:
126      name: The name of the check.
127      summary: A str in Markdown to be used as the detail information of the check.
128      success: A bool indicates whether the check is succeed or not.
129    """
130    if 'KOKORO_GIT_COMMIT' not in os.environ:
131        print('Missing KOKORO_GIT_COMMIT env var: not checking')
132        return
133    if 'KOKORO_KEYSTORE_DIR' not in os.environ:
134        print('Missing KOKORO_KEYSTORE_DIR env var: not checking')
135        return
136    if 'KOKORO_GITHUB_PULL_REQUEST_NUMBER' not in os.environ:
137        print('Missing KOKORO_GITHUB_PULL_REQUEST_NUMBER env var: not checking')
138        return
139    MAX_SUMMARY_LEN = 65400
140    if len(summary) > MAX_SUMMARY_LEN:
141        # Drop some hints to the log should someone come looking for what really happened!
142        print('Clipping too long summary')
143        print(summary)
144        summary = summary[:MAX_SUMMARY_LEN] + '\n\n\n... CLIPPED (too long)'
145    completion_time = str(
146        datetime.datetime.utcnow().replace(microsecond=0).isoformat()) + 'Z'
147    resp = _call('/repos/%s/check-runs' % _GITHUB_REPO,
148                 method='POST',
149                 json={
150                     'name': name,
151                     'head_sha': os.environ['KOKORO_GIT_COMMIT'],
152                     'status': 'completed',
153                     'completed_at': completion_time,
154                     'conclusion': 'success' if success else 'failure',
155                     'output': {
156                         'title': name,
157                         'summary': summary,
158                     }
159                 })
160    print('Result of Creating/Updating Check on PR:',
161          json.dumps(resp.json(), indent=2))
162
163
164def label_significance_on_pr(name, change, labels=_CHANGE_LABELS):
165    """Add a label to the PR indicating the significance of the check.
166
167    Requires environment variable 'KOKORO_GITHUB_PULL_REQUEST_NUMBER' to indicate which pull request
168    should be updated.
169
170    Args:
171      name: The name of the label.
172      value: A str in Markdown to be used as the detail information of the label.
173    """
174    if change < min(list(labels.keys())):
175        change = min(list(labels.keys()))
176    if change > max(list(labels.keys())):
177        change = max(list(labels.keys()))
178    value = labels[change]
179    if 'KOKORO_GIT_COMMIT' not in os.environ:
180        print('Missing KOKORO_GIT_COMMIT env var: not checking')
181        return
182    if 'KOKORO_KEYSTORE_DIR' not in os.environ:
183        print('Missing KOKORO_KEYSTORE_DIR env var: not checking')
184        return
185    if 'KOKORO_GITHUB_PULL_REQUEST_NUMBER' not in os.environ:
186        print('Missing KOKORO_GITHUB_PULL_REQUEST_NUMBER env var: not checking')
187        return
188    existing = _call(
189        '/repos/%s/issues/%s/labels' %
190        (_GITHUB_REPO, os.environ['KOKORO_GITHUB_PULL_REQUEST_NUMBER']),
191        method='GET').json()
192    print('Result of fetching labels on PR:', existing)
193    new = [x['name'] for x in existing if not x['name'].startswith(name + '/')]
194    new.append(name + '/' + value)
195    resp = _call(
196        '/repos/%s/issues/%s/labels' %
197        (_GITHUB_REPO, os.environ['KOKORO_GITHUB_PULL_REQUEST_NUMBER']),
198        method='PUT',
199        json=new)
200    print('Result of setting labels on PR:', resp.text)
201
202
203def label_increase_decrease_on_pr(name, change, significant):
204    if change <= -significant:
205        label_significance_on_pr(name, -1, _INCREASE_DECREASE)
206    elif change >= significant:
207        label_significance_on_pr(name, 1, _INCREASE_DECREASE)
208    else:
209        label_significance_on_pr(name, 0, _INCREASE_DECREASE)
210