~ubuntu-archive/ubuntu-archive-scripts/trunk

225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
1
#!/usr/bin/env python3
2
# -*- coding: utf-8 -*-
3
4
# Copyright (C) 2018 Canonical Ltd
5
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# A copy of the GNU General Public License version 2 is in LICENSE.
17
18
import argparse
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
19
from collections import defaultdict, OrderedDict
269.1.2 by Sebastien Bacher
team-report: include the timezone in the update timestamp
20
import datetime
21
import time
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
22
import json
23
import os
24
import threading
25
from urllib.request import urlopen
281.1.7 by Sebastien Bacher
specify the urllib error to catch
26
import urllib.error
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
27
28
import attr
29
from jinja2 import Environment, FileSystemLoader
30
import yaml
281.1.5 by Sebastien Bacher
try reading the xz report first, if not fallback to the uncompressed one
31
import lzma
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
32
33
env = Environment(
34
    loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__)) + '/templates'),
35
    autoescape=True,
36
    extensions=['jinja2.ext.i18n'],
37
)
38
env.install_null_translations(True)
39
40
_lps = {}
41
42
def get_lp(i, anon=True):
43
    from launchpadlib.launchpad import Launchpad
44
    k = (i, anon)
45
    if k not in _lps:
46
        print(i, "connecting...")
47
        if anon:
48
            _lps[k] = Launchpad.login_anonymously('sru-team-report', 'production', version='devel')
49
        else:
50
            _lps[k] = Launchpad.login_with('sru-team-report', 'production', version='devel')
51
    return _lps[k]
52
53
54
def get_true_ages_in_proposed(package_names, thread_count):
55
    package_names = set(package_names)
56
    r = {}
57
    def run(i):
58
        lp = get_lp(i, True)
59
        ubuntu = lp.distributions['ubuntu']
60
        primary_archive = ubuntu.archives[0]
61
        devel_series = ubuntu.getSeries(name_or_version=ubuntu.development_series_alias)
62
        while True:
63
            try:
64
                spn = package_names.pop()
65
            except KeyError:
66
                return
67
            print(i, "getting true age in proposed for", spn)
68
            history = primary_archive.getPublishedSources(
69
                source_name=spn, distro_series=devel_series, exact_match=True)
70
            last_proposed_spph = None
71
            for spph in history:
72
                if spph.pocket != "Proposed":
73
                    break
74
                last_proposed_spph = spph
75
            if last_proposed_spph is None:
76
                continue
77
            age = datetime.datetime.now(tz=last_proposed_spph.date_created.tzinfo) - last_proposed_spph.date_created
78
            r[spn] = age.total_seconds() / 3600 / 24
79
    threads = []
80
    for i in range(thread_count):
81
        t = threading.Thread(target=run, args=(i,))
82
        threads.append(t)
83
        t.start()
84
    for t in threads:
85
        t.join()
86
    return r
87
88
89
def get_subscribers_lp(packages, thread_count):
90
    from lazr.restfulclient.errors import ClientError
91
    packages = set(packages)
92
    def run(i, subscribers):
93
        lp = get_lp(i, False)
94
        ubuntu = lp.distributions['ubuntu']
95
        while True:
96
            try:
97
                spn = packages.pop()
98
            except KeyError:
99
                return
100
            print(i, spn)
101
            distribution_source_package = ubuntu.getSourcePackage(name=spn)
102
            for subscription in distribution_source_package.getSubscriptions():
103
                subscriber = subscription.subscriber
104
                try:
105
                    if subscriber.is_team:
106
                        subscribers[spn].append(subscriber.name)
107
                except ClientError:
108
                    # This happens for suspended users
109
                    pass
110
    results = []
111
    threads = []
112
    for i in range(thread_count):
113
        d = defaultdict(list)
114
        t = threading.Thread(target=run, args=(i, d))
115
        results.append(d)
116
        threads.append(t)
117
        t.start()
118
    for t in threads:
119
        t.join()
120
    result = defaultdict(list)
121
    for d in results:
122
        for k, v in d.items():
123
            result[k].extend(v)
124
    return result
125
126
def get_subscribers_json(packages, subscribers_json):
127
    if subscribers_json is None:
128
        j = urlopen("http://people.canonical.com/~ubuntu-archive/package-team-mapping.json")
129
    else:
130
        j = open(subscribers_json, 'rb')
131
    with j:
132
        team_to_packages = json.loads(j.read().decode('utf-8'))
133
    package_to_teams = {}
134
    for team, packages in team_to_packages.items():
135
        for package in packages:
136
            package_to_teams.setdefault(package, []).append(team)
137
    return package_to_teams
138
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
139
def setup_yaml():
140
    """ http://stackoverflow.com/a/8661021 """
141
    represent_dict_order = (
142
        lambda self, data: self.represent_mapping('tag:yaml.org,2002:map',
143
                                                  data.items()))
144
    yaml.add_representer(OrderedDict, represent_dict_order)
145
146
147
setup_yaml()
148
149
def as_data(inst):
150
    r = OrderedDict()
151
    fields = [field.name for field in attr.fields(type(inst))]
152
    fields.extend(getattr(inst, "extra_fields", []))
153
    for field in fields:
154
        if field.startswith('_'):
155
            continue
156
        v = getattr(
157
            inst,
158
            'serialize_' + field,
159
            lambda: getattr(inst, field))()
160
        if v is not None:
161
            r[field] = v
162
    return r
163
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
164
@attr.s
165
class ArchRegression:
166
    arch = attr.ib(default=None)
167
    log_link = attr.ib(default=None)
168
    hist_link = attr.ib(default=None)
169
170
171
@attr.s
172
class Regression:
173
    blocking = attr.ib(default=None) # source package name blocked
174
    package = attr.ib(default=None) # source package name doing the blocking
175
    version = attr.ib(default=None) # version that regressed
176
    arches = attr.ib(default=None) # [ArchRegression]
177
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
178
    def serialize_arches(self):
179
        return [as_data(a) for a in self.arches]
180
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
181
    @property
182
    def package_version(self):
183
        return self.package + '/' + self.version
184
185
@attr.s
186
class Problem:
187
    kind = attr.ib(default=None) # 'blocked-in-proposed', 'regressing-other'
188
    package_in_proposed = attr.ib(default=None) # name of package that's in proposed
189
    regressing_package = attr.ib(default=None) # name of package regressing package_in_proposed, None if blocked-in-proposed
190
    regressions = attr.ib(default=None) # [Regression]
191
    waiting = attr.ib(default=None) # [(source_package_name, arches)]
192
    data = attr.ib(default=None) # data for package_in_proposed
193
    unsatdepends = attr.ib(default=None) # [string]
288.1.1 by Sebastien Bacher
Include unsatisfiable Build-Depends in the team report
194
    unsatbuilddep = attr.ib(default=None) # [string]
291 by Sebastien Bacher
Displays the broken binaries information in the teams report.
195
    brokenbin = attr.ib(default=None) # [string]
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
196
197
    _age = attr.ib(default=None)
198
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
199
    extra_fields = ['age']
200
201
    def serialize_regressions(self):
202
        return [as_data(r) for r in self.regressions]
203
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
204
    @property
225.1.2 by Michael Hudson-Doyle
update script and template
205
    def late(self):
206
        return self.age > 3
207
208
    @property
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
209
    def age(self):
210
        if self._age is not None:
211
            return self._age
212
        else:
270.1.1 by Iain Lane
generate-team-p-m: Handle a missing policy_info
213
            try:
214
                return self.data["policy_info"]["age"]["current-age"]
215
            except KeyError:
216
                return -1
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
217
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
218
    @age.setter
219
    def age(self, val):
220
        self._age = val
221
225.1.2 by Michael Hudson-Doyle
update script and template
222
    @property
223
    def key_package(self):
224
        if self.regressing_package:
225
            return self.regressing_package
226
        return self.package_in_proposed
227
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
228
229
def main():
230
    parser = argparse.ArgumentParser()
231
    parser.add_argument('--ppa', action='store')
232
    parser.add_argument('--components', action='store', default="main,restricted")
233
    parser.add_argument('--subscribers-from-lp', action='store_true')
234
    parser.add_argument('--subscribers-json', action='store')
235
    parser.add_argument('--true-ages', action='store_true')
236
    parser.add_argument('--excuses-yaml', action='store')
237
    parser.add_argument('output')
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
238
    parser.add_argument('yaml_output', default=None)
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
239
    args = parser.parse_args()
240
241
    components = args.components.split(',')
242
243
    print("fetching yaml")
244
    if args.excuses_yaml:
282.1.1 by Iain Lane
generate-team-p-m, run-proposed-migration: More updates for .xz excuses
245
        if args.excuses_yaml.endswith('.xz'):
246
            yaml_text = lzma.open(args.excuses_yaml)
247
        else:
248
            yaml_text = open(args.excuses_yaml)
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
249
    else:
281.1.5 by Sebastien Bacher
try reading the xz report first, if not fallback to the uncompressed one
250
        try:
251
            yaml_text = lzma.open(urlopen("https://people.canonical.com/~ubuntu-archive/proposed-migration/update_excuses.yaml.xz"))
281.1.8 by Sebastien Bacher
indicate when the fallback url is read and display the error
252
        except urllib.error.HTTPError as e:
253
            print("Reading fallback yaml (%s)" % e)
281.1.6 by Sebastien Bacher
there is no need to read the report before iterating
254
            yaml_text = urlopen("https://people.canonical.com/~ubuntu-archive/proposed-migration/update_excuses.yaml")
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
255
    print("parsing yaml")
256
    # The CSafeLoader is ten times faster than the regular one
257
    excuses = yaml.load(yaml_text, Loader=yaml.CSafeLoader)
258
259
    print("pre-processing packages")
260
    in_proposed_packages = {}
261
    for item in excuses["sources"]:
262
        source_package_name = item['item-name']
263
        # Missing component means main
264
        if item.get('component', 'main') not in components:
265
            continue
270.1.1 by Iain Lane
generate-team-p-m: Handle a missing policy_info
266
        prob = Problem(kind='package-in-proposed', data=defaultdict(dict, item), package_in_proposed=source_package_name)
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
267
        in_proposed_packages[source_package_name] = prob
268
        prob.regressions = []
269
        prob.waiting = []
281.1.9 by Sebastien Bacher
don't special case verdicts entries, they are filter out now
270
        # The verdict entries are not items to list on the report
281.1.3 by Sebastien Bacher
exclude the verdit entries from the code it's simpler, thanks Laney!
271
        for policy in ['autopkgtest', 'update-excuse', 'block-bugs']:
272
            try:
273
                del item['policy_info'][policy]['verdict']
274
            except KeyError:
275
                pass
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
276
        if 'autopkgtest' in item['reason']:
277
            for package, results in sorted(item['policy_info']['autopkgtest'].items()):
278
                regr_arches = []
279
                wait_arches = []
280
                for arch, result in sorted(results.items()):
281
                    outcome, log, history, wtf1, wtf2 = result
282
                    if outcome == "REGRESSION":
283
                        regr_arches.append(ArchRegression(arch=arch, log_link=log, hist_link=history))
284
                    if outcome == "RUNNING":
285
                        wait_arches.append(arch)
286
                if regr_arches:
287
                    p, v = package.split('/')
288
                    regr = Regression(package=p, version=v, blocking=source_package_name)
289
                    regr.arches = regr_arches
290
                    prob.regressions.append(regr)
291
                if wait_arches:
292
                    prob.waiting.append((package + ": " + ", ".join(wait_arches)))
293
        if 'dependencies' in item and 'unsatisfiable-dependencies' in item['dependencies']:
294
                unsatd = defaultdict(list)
295
                for arch, packages in item['dependencies']['unsatisfiable-dependencies'].items():
296
                    for p in packages:
297
                        unsatd[p].append(arch)
298
                prob.unsatdepends = ['{}: {}'.format(p, ', '.join(sorted(arches))) for p, arches in sorted(unsatd.items())]
288.1.1 by Sebastien Bacher
Include unsatisfiable Build-Depends in the team report
299
        if 'build-depends' in item['policy_info'] and 'unsatisfiable-arch-build-depends' in item['policy_info']['build-depends']:
300
                unsatdbd = defaultdict(list)
301
                for arch, packages in item['policy_info']['build-depends']['unsatisfiable-arch-build-depends'].items():
302
                    for p in packages:
303
                        unsatdbd[p].append(arch)
304
                prob.unsatbuilddep = ['{}: {}'.format(p, ', '.join(sorted(arches))) for p, arches in sorted(unsatdbd.items())]
291 by Sebastien Bacher
Displays the broken binaries information in the teams report.
305
        if 'implicit-deps' in item['policy_info']['implicit-deps']:
306
                if item['policy_info']['implicit-deps']['implicit-deps']['broken-binaries']:
307
                        brokenbin = []
308
                        for bin in item['policy_info']['implicit-deps']['implicit-deps']['broken-binaries']:
309
                                brokenbin.append(bin)
310
                                prob.brokenbin = brokenbin
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
311
312
    package_to_problems = defaultdict(list)
313
314
    for problem in in_proposed_packages.values():
270.1.2 by Iain Lane
generate-team-p-m: Attribute binary-only migration items to the right team
315
        # nautilus/riscv64 -> nautilus
316
        pkg = problem.package_in_proposed.split('/')[0]
317
        package_to_problems[pkg].append(problem)
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
318
        for regression in problem.regressions:
319
            if regression.blocking not in in_proposed_packages:
320
                continue
321
            if regression.blocking == regression.package:
322
                continue
323
            package_to_problems[regression.package].append(Problem(
324
                kind='regressing-other', package_in_proposed=regression.blocking,
325
                regressing_package=regression.package,
326
                regressions=[regression],
327
                data=in_proposed_packages[regression.blocking].data))
328
329
    if args.true_ages:
330
        true_ages = get_true_ages_in_proposed(set(package_to_problems), 10)
331
        for package, true_age in true_ages.items():
332
            for problem in package_to_problems[package]:
333
                problem.age = true_age
334
335
    print("getting subscribers")
336
    if args.subscribers_from_lp:
337
        subscribers = get_subscribers_lp(set(package_to_problems), 10)
338
        for p in set(package_to_problems):
339
            if p not in subscribers:
340
                subscribers[p] = ['unsubscribed']
341
    else:
342
        subscribers = get_subscribers_json(set(package_to_problems), args.subscribers_json)
343
        for p in set(package_to_problems):
270.1.2 by Iain Lane
generate-team-p-m: Attribute binary-only migration items to the right team
344
            pkg = p.split('/')[0]
345
            if pkg not in subscribers:
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
346
                subscribers[p] = ['unknown']
347
348
    all_teams = set()
349
    team_to_problems = defaultdict(list)
350
    for package, teams in subscribers.items():
351
        all_teams |= set(teams)
352
        for team in teams:
353
            team_to_problems[team].extend(package_to_problems[package])
354
355
    for packages in team_to_problems.values():
225.1.2 by Michael Hudson-Doyle
update script and template
356
        packages.sort(key=lambda prob: (-prob.age, prob.key_package))
357
358
    team_to_attn_count = {}
359
    for team, problems in team_to_problems.items():
360
        team_to_attn_count[team] = len([problem for problem in problems if problem.late])
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
361
362
    print("rendering")
363
    t = env.get_template('team-report.html')
364
    with open(args.output, 'w', encoding='utf-8') as fp:
365
        fp.write(t.render(
366
            all_teams=all_teams,
367
            team_to_problems=team_to_problems,
225.1.2 by Michael Hudson-Doyle
update script and template
368
            team_to_attn_count=team_to_attn_count,
269.1.1 by Sebastien Bacher
team-report: include the timezone in the update timestamp
369
            now=excuses["generated-date"].strftime("%Y.%m.%d %H:%M:%S") + ' ' + time.localtime().tm_zone))
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
370
    if args.yaml_output:
371
        team_to_problem_data = {}
372
        for t, ps in team_to_problems.items():
373
            team_to_problem_data[t] = [as_data(p) for p in ps]
374
        with open(args.yaml_output, 'w', encoding='utf-8') as fp:
375
            yaml.dump(team_to_problem_data, fp)
376
225.1.1 by Michael Hudson-Doyle
add a script to generate a per-team view of proposed-migration and invoke it after proposed migration
377
378
if __name__ == '__main__':
379
    main()