~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:
349 by Steve Langasek
ubuntu-archive-team is https-only
128
        j = urlopen("https://ubuntu-archive-team.ubuntu.com/package-team-mapping.json")
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
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]
295.2.1 by Sebastien Bacher
Display component mismatch information in the team report
196
    componentmismatch = 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
197
198
    _age = attr.ib(default=None)
199
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
200
    extra_fields = ['age']
201
202
    def serialize_regressions(self):
203
        return [as_data(r) for r in self.regressions]
204
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
205
    @property
225.1.2 by Michael Hudson-Doyle
update script and template
206
    def late(self):
207
        return self.age > 3
208
209
    @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
210
    def age(self):
211
        if self._age is not None:
212
            return self._age
213
        else:
270.1.1 by Iain Lane
generate-team-p-m: Handle a missing policy_info
214
            try:
215
                return self.data["policy_info"]["age"]["current-age"]
216
            except KeyError:
217
                return -1
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
218
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
219
    @age.setter
220
    def age(self, val):
221
        self._age = val
222
225.1.2 by Michael Hudson-Doyle
update script and template
223
    @property
224
    def key_package(self):
225
        if self.regressing_package:
226
            return self.regressing_package
227
        return self.package_in_proposed
228
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
229
230
def main():
231
    parser = argparse.ArgumentParser()
232
    parser.add_argument('--ppa', action='store')
233
    parser.add_argument('--components', action='store', default="main,restricted")
234
    parser.add_argument('--subscribers-from-lp', action='store_true')
235
    parser.add_argument('--subscribers-json', action='store')
236
    parser.add_argument('--true-ages', action='store_true')
237
    parser.add_argument('--excuses-yaml', action='store')
238
    parser.add_argument('output')
234.1.1 by Michael Hudson-Doyle
yaml output for generate-team-p-m
239
    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
240
    args = parser.parse_args()
241
242
    components = args.components.split(',')
243
244
    print("fetching yaml")
245
    if args.excuses_yaml:
282.1.1 by Iain Lane
generate-team-p-m, run-proposed-migration: More updates for .xz excuses
246
        if args.excuses_yaml.endswith('.xz'):
247
            yaml_text = lzma.open(args.excuses_yaml)
248
        else:
249
            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
250
    else:
281.1.5 by Sebastien Bacher
try reading the xz report first, if not fallback to the uncompressed one
251
        try:
348 by Steve Langasek
ubuntu-archive-team.ubuntu.com is now the canonical URL
252
            yaml_text = lzma.open(urlopen("https://ubuntu-archive-team.ubuntu.com/proposed-migration/update_excuses.yaml.xz"))
281.1.8 by Sebastien Bacher
indicate when the fallback url is read and display the error
253
        except urllib.error.HTTPError as e:
254
            print("Reading fallback yaml (%s)" % e)
348 by Steve Langasek
ubuntu-archive-team.ubuntu.com is now the canonical URL
255
            yaml_text = urlopen("https://ubuntu-archive-team.ubuntu.com/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
256
    print("parsing yaml")
257
    # The CSafeLoader is ten times faster than the regular one
258
    excuses = yaml.load(yaml_text, Loader=yaml.CSafeLoader)
259
260
    print("pre-processing packages")
261
    in_proposed_packages = {}
262
    for item in excuses["sources"]:
263
        source_package_name = item['item-name']
264
        # Missing component means main
265
        if item.get('component', 'main') not in components:
266
            continue
270.1.1 by Iain Lane
generate-team-p-m: Handle a missing policy_info
267
        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
268
        in_proposed_packages[source_package_name] = prob
269
        prob.regressions = []
270
        prob.waiting = []
295.2.1 by Sebastien Bacher
Display component mismatch information in the team report
271
        prob.componentmismatch = []
281.1.9 by Sebastien Bacher
don't special case verdicts entries, they are filter out now
272
        # 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!
273
        for policy in ['autopkgtest', 'update-excuse', 'block-bugs']:
274
            try:
275
                del item['policy_info'][policy]['verdict']
276
            except KeyError:
277
                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
278
        if 'autopkgtest' in item['reason']:
279
            for package, results in sorted(item['policy_info']['autopkgtest'].items()):
280
                regr_arches = []
281
                wait_arches = []
282
                for arch, result in sorted(results.items()):
283
                    outcome, log, history, wtf1, wtf2 = result
284
                    if outcome == "REGRESSION":
285
                        regr_arches.append(ArchRegression(arch=arch, log_link=log, hist_link=history))
286
                    if outcome == "RUNNING":
287
                        wait_arches.append(arch)
288
                if regr_arches:
289
                    p, v = package.split('/')
290
                    regr = Regression(package=p, version=v, blocking=source_package_name)
291
                    regr.arches = regr_arches
292
                    prob.regressions.append(regr)
293
                if wait_arches:
294
                    prob.waiting.append((package + ": " + ", ".join(wait_arches)))
295.2.1 by Sebastien Bacher
Display component mismatch information in the team report
295
        if 'depends' in item['reason']:
296
            for l in item['excuses']:
297
                if 'cannot depend on' in l:
298
                    prob.componentmismatch.append(l)
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
299
        if 'dependencies' in item and 'unsatisfiable-dependencies' in item['dependencies']:
300
                unsatd = defaultdict(list)
301
                for arch, packages in item['dependencies']['unsatisfiable-dependencies'].items():
302
                    for p in packages:
303
                        unsatd[p].append(arch)
304
                prob.unsatdepends = ['{}: {}'.format(p, ', '.join(sorted(arches))) for p, arches in sorted(unsatd.items())]
311.1.1 by Brian Murray
don't assume policy_info exists because this one time it didn't
305
        if 'policy_info' in item:
306
            if 'build-depends' in item['policy_info'] and 'unsatisfiable-arch-build-depends' in item['policy_info']['build-depends']:
307
                    unsatdbd = defaultdict(list)
308
                    for arch, packages in item['policy_info']['build-depends']['unsatisfiable-arch-build-depends'].items():
309
                        for p in packages:
310
                            unsatdbd[p].append(arch)
311
                    prob.unsatbuilddep = ['{}: {}'.format(p, ', '.join(sorted(arches))) for p, arches in sorted(unsatdbd.items())]
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
312
313
    package_to_problems = defaultdict(list)
314
315
    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
316
        # nautilus/riscv64 -> nautilus
317
        pkg = problem.package_in_proposed.split('/')[0]
318
        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
319
        for regression in problem.regressions:
320
            if regression.blocking not in in_proposed_packages:
321
                continue
322
            if regression.blocking == regression.package:
323
                continue
324
            package_to_problems[regression.package].append(Problem(
325
                kind='regressing-other', package_in_proposed=regression.blocking,
326
                regressing_package=regression.package,
327
                regressions=[regression],
328
                data=in_proposed_packages[regression.blocking].data))
329
330
    if args.true_ages:
331
        true_ages = get_true_ages_in_proposed(set(package_to_problems), 10)
332
        for package, true_age in true_ages.items():
333
            for problem in package_to_problems[package]:
334
                problem.age = true_age
335
336
    print("getting subscribers")
337
    if args.subscribers_from_lp:
338
        subscribers = get_subscribers_lp(set(package_to_problems), 10)
339
        for p in set(package_to_problems):
340
            if p not in subscribers:
341
                subscribers[p] = ['unsubscribed']
342
    else:
343
        subscribers = get_subscribers_json(set(package_to_problems), args.subscribers_json)
344
        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
345
            pkg = p.split('/')[0]
346
            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
347
                subscribers[p] = ['unknown']
348
349
    all_teams = set()
350
    team_to_problems = defaultdict(list)
351
    for package, teams in subscribers.items():
352
        all_teams |= set(teams)
353
        for team in teams:
354
            team_to_problems[team].extend(package_to_problems[package])
355
356
    for packages in team_to_problems.values():
225.1.2 by Michael Hudson-Doyle
update script and template
357
        packages.sort(key=lambda prob: (-prob.age, prob.key_package))
358
359
    team_to_attn_count = {}
360
    for team, problems in team_to_problems.items():
361
        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
362
363
    print("rendering")
364
    t = env.get_template('team-report.html')
365
    with open(args.output, 'w', encoding='utf-8') as fp:
366
        fp.write(t.render(
367
            all_teams=all_teams,
368
            team_to_problems=team_to_problems,
225.1.2 by Michael Hudson-Doyle
update script and template
369
            team_to_attn_count=team_to_attn_count,
269.1.1 by Sebastien Bacher
team-report: include the timezone in the update timestamp
370
            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
371
    if args.yaml_output:
372
        team_to_problem_data = {}
373
        for t, ps in team_to_problems.items():
374
            team_to_problem_data[t] = [as_data(p) for p in ps]
375
        with open(args.yaml_output, 'w', encoding='utf-8') as fp:
376
            yaml.dump(team_to_problem_data, fp)
377
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
378
379
if __name__ == '__main__':
380
    main()