Merge lp:~mwhudson/ubuntu-archive-scripts/add-team-report into lp:ubuntu-archive-scripts
- add-team-report
- Merge into trunk
Proposed by
Michael Hudson-Doyle
Status: | Merged |
---|---|
Merged at revision: | 226 |
Proposed branch: | lp:~mwhudson/ubuntu-archive-scripts/add-team-report |
Merge into: | lp:ubuntu-archive-scripts |
Diff against target: |
431 lines (+413/-0) 3 files modified
generate-team-p-m (+301/-0) run-proposed-migration (+5/-0) templates/team-report.html (+107/-0) |
To merge this branch: | bzr merge lp:~mwhudson/ubuntu-archive-scripts/add-team-report |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Steve Langasek | Approve | ||
Review via email: mp+354278@code.launchpad.net |
Commit message
Description of the change
Seemed the easiest approach, happy to be told to do it differently :)
This will require python3-attr and python3-jinja2 to be installed on snakefruit before deployment.
To post a comment you must log in.
- 227. By Michael Hudson-Doyle
-
update script and template
Revision history for this message
Steve Langasek (vorlon) : | # |
review:
Needs Fixing
- 228. By Michael Hudson-Doyle
-
review feedback
Revision history for this message
Steve Langasek (vorlon) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === added file 'generate-team-p-m' | |||
2 | --- generate-team-p-m 1970-01-01 00:00:00 +0000 | |||
3 | +++ generate-team-p-m 2018-09-11 23:51:09 +0000 | |||
4 | @@ -0,0 +1,301 @@ | |||
5 | 1 | #!/usr/bin/env python3 | ||
6 | 2 | # -*- coding: utf-8 -*- | ||
7 | 3 | |||
8 | 4 | # Copyright (C) 2018 Canonical Ltd | ||
9 | 5 | |||
10 | 6 | # This program is free software; you can redistribute it and/or | ||
11 | 7 | # modify it under the terms of the GNU General Public License | ||
12 | 8 | # as published by the Free Software Foundation; either version 2 | ||
13 | 9 | # of the License, or (at your option) any later version. | ||
14 | 10 | # | ||
15 | 11 | # This program is distributed in the hope that it will be useful, | ||
16 | 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
17 | 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
18 | 14 | # GNU General Public License for more details. | ||
19 | 15 | # | ||
20 | 16 | # A copy of the GNU General Public License version 2 is in LICENSE. | ||
21 | 17 | |||
22 | 18 | import argparse | ||
23 | 19 | from collections import defaultdict | ||
24 | 20 | import datetime | ||
25 | 21 | import json | ||
26 | 22 | import os | ||
27 | 23 | import threading | ||
28 | 24 | from urllib.request import urlopen | ||
29 | 25 | |||
30 | 26 | import attr | ||
31 | 27 | from jinja2 import Environment, FileSystemLoader | ||
32 | 28 | import yaml | ||
33 | 29 | |||
34 | 30 | env = Environment( | ||
35 | 31 | loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__)) + '/templates'), | ||
36 | 32 | autoescape=True, | ||
37 | 33 | extensions=['jinja2.ext.i18n'], | ||
38 | 34 | ) | ||
39 | 35 | env.install_null_translations(True) | ||
40 | 36 | |||
41 | 37 | _lps = {} | ||
42 | 38 | |||
43 | 39 | def get_lp(i, anon=True): | ||
44 | 40 | from launchpadlib.launchpad import Launchpad | ||
45 | 41 | k = (i, anon) | ||
46 | 42 | if k not in _lps: | ||
47 | 43 | print(i, "connecting...") | ||
48 | 44 | if anon: | ||
49 | 45 | _lps[k] = Launchpad.login_anonymously('sru-team-report', 'production', version='devel') | ||
50 | 46 | else: | ||
51 | 47 | _lps[k] = Launchpad.login_with('sru-team-report', 'production', version='devel') | ||
52 | 48 | return _lps[k] | ||
53 | 49 | |||
54 | 50 | |||
55 | 51 | def get_true_ages_in_proposed(package_names, thread_count): | ||
56 | 52 | package_names = set(package_names) | ||
57 | 53 | r = {} | ||
58 | 54 | def run(i): | ||
59 | 55 | lp = get_lp(i, True) | ||
60 | 56 | ubuntu = lp.distributions['ubuntu'] | ||
61 | 57 | primary_archive = ubuntu.archives[0] | ||
62 | 58 | devel_series = ubuntu.getSeries(name_or_version=ubuntu.development_series_alias) | ||
63 | 59 | while True: | ||
64 | 60 | try: | ||
65 | 61 | spn = package_names.pop() | ||
66 | 62 | except KeyError: | ||
67 | 63 | return | ||
68 | 64 | print(i, "getting true age in proposed for", spn) | ||
69 | 65 | history = primary_archive.getPublishedSources( | ||
70 | 66 | source_name=spn, distro_series=devel_series, exact_match=True) | ||
71 | 67 | last_proposed_spph = None | ||
72 | 68 | for spph in history: | ||
73 | 69 | if spph.pocket != "Proposed": | ||
74 | 70 | break | ||
75 | 71 | last_proposed_spph = spph | ||
76 | 72 | if last_proposed_spph is None: | ||
77 | 73 | continue | ||
78 | 74 | age = datetime.datetime.now(tz=last_proposed_spph.date_created.tzinfo) - last_proposed_spph.date_created | ||
79 | 75 | r[spn] = age.total_seconds() / 3600 / 24 | ||
80 | 76 | threads = [] | ||
81 | 77 | for i in range(thread_count): | ||
82 | 78 | t = threading.Thread(target=run, args=(i,)) | ||
83 | 79 | threads.append(t) | ||
84 | 80 | t.start() | ||
85 | 81 | for t in threads: | ||
86 | 82 | t.join() | ||
87 | 83 | return r | ||
88 | 84 | |||
89 | 85 | |||
90 | 86 | def get_subscribers_lp(packages, thread_count): | ||
91 | 87 | from lazr.restfulclient.errors import ClientError | ||
92 | 88 | packages = set(packages) | ||
93 | 89 | def run(i, subscribers): | ||
94 | 90 | lp = get_lp(i, False) | ||
95 | 91 | ubuntu = lp.distributions['ubuntu'] | ||
96 | 92 | while True: | ||
97 | 93 | try: | ||
98 | 94 | spn = packages.pop() | ||
99 | 95 | except KeyError: | ||
100 | 96 | return | ||
101 | 97 | print(i, spn) | ||
102 | 98 | distribution_source_package = ubuntu.getSourcePackage(name=spn) | ||
103 | 99 | for subscription in distribution_source_package.getSubscriptions(): | ||
104 | 100 | subscriber = subscription.subscriber | ||
105 | 101 | try: | ||
106 | 102 | if subscriber.is_team: | ||
107 | 103 | subscribers[spn].append(subscriber.name) | ||
108 | 104 | except ClientError: | ||
109 | 105 | # This happens for suspended users | ||
110 | 106 | pass | ||
111 | 107 | results = [] | ||
112 | 108 | threads = [] | ||
113 | 109 | for i in range(thread_count): | ||
114 | 110 | d = defaultdict(list) | ||
115 | 111 | t = threading.Thread(target=run, args=(i, d)) | ||
116 | 112 | results.append(d) | ||
117 | 113 | threads.append(t) | ||
118 | 114 | t.start() | ||
119 | 115 | for t in threads: | ||
120 | 116 | t.join() | ||
121 | 117 | result = defaultdict(list) | ||
122 | 118 | for d in results: | ||
123 | 119 | for k, v in d.items(): | ||
124 | 120 | result[k].extend(v) | ||
125 | 121 | return result | ||
126 | 122 | |||
127 | 123 | def get_subscribers_json(packages, subscribers_json): | ||
128 | 124 | if subscribers_json is None: | ||
129 | 125 | j = urlopen("http://people.canonical.com/~ubuntu-archive/package-team-mapping.json") | ||
130 | 126 | else: | ||
131 | 127 | j = open(subscribers_json, 'rb') | ||
132 | 128 | with j: | ||
133 | 129 | team_to_packages = json.loads(j.read().decode('utf-8')) | ||
134 | 130 | package_to_teams = {} | ||
135 | 131 | for team, packages in team_to_packages.items(): | ||
136 | 132 | for package in packages: | ||
137 | 133 | package_to_teams.setdefault(package, []).append(team) | ||
138 | 134 | return package_to_teams | ||
139 | 135 | |||
140 | 136 | @attr.s | ||
141 | 137 | class ArchRegression: | ||
142 | 138 | arch = attr.ib(default=None) | ||
143 | 139 | log_link = attr.ib(default=None) | ||
144 | 140 | hist_link = attr.ib(default=None) | ||
145 | 141 | |||
146 | 142 | |||
147 | 143 | @attr.s | ||
148 | 144 | class Regression: | ||
149 | 145 | blocking = attr.ib(default=None) # source package name blocked | ||
150 | 146 | package = attr.ib(default=None) # source package name doing the blocking | ||
151 | 147 | version = attr.ib(default=None) # version that regressed | ||
152 | 148 | arches = attr.ib(default=None) # [ArchRegression] | ||
153 | 149 | |||
154 | 150 | @property | ||
155 | 151 | def package_version(self): | ||
156 | 152 | return self.package + '/' + self.version | ||
157 | 153 | |||
158 | 154 | @attr.s | ||
159 | 155 | class Problem: | ||
160 | 156 | kind = attr.ib(default=None) # 'blocked-in-proposed', 'regressing-other' | ||
161 | 157 | package_in_proposed = attr.ib(default=None) # name of package that's in proposed | ||
162 | 158 | regressing_package = attr.ib(default=None) # name of package regressing package_in_proposed, None if blocked-in-proposed | ||
163 | 159 | regressions = attr.ib(default=None) # [Regression] | ||
164 | 160 | waiting = attr.ib(default=None) # [(source_package_name, arches)] | ||
165 | 161 | data = attr.ib(default=None) # data for package_in_proposed | ||
166 | 162 | unsatdepends = attr.ib(default=None) # [string] | ||
167 | 163 | |||
168 | 164 | _age = attr.ib(default=None) | ||
169 | 165 | |||
170 | 166 | @property | ||
171 | 167 | def late(self): | ||
172 | 168 | return self.age > 3 | ||
173 | 169 | |||
174 | 170 | @property | ||
175 | 171 | def age(self): | ||
176 | 172 | if self._age is not None: | ||
177 | 173 | return self._age | ||
178 | 174 | else: | ||
179 | 175 | return self.data["policy_info"]["age"]["current-age"] | ||
180 | 176 | @age.setter | ||
181 | 177 | def age(self, val): | ||
182 | 178 | self._age = val | ||
183 | 179 | |||
184 | 180 | @property | ||
185 | 181 | def key_package(self): | ||
186 | 182 | if self.regressing_package: | ||
187 | 183 | return self.regressing_package | ||
188 | 184 | return self.package_in_proposed | ||
189 | 185 | |||
190 | 186 | |||
191 | 187 | def main(): | ||
192 | 188 | parser = argparse.ArgumentParser() | ||
193 | 189 | parser.add_argument('--ppa', action='store') | ||
194 | 190 | parser.add_argument('--components', action='store', default="main,restricted") | ||
195 | 191 | parser.add_argument('--subscribers-from-lp', action='store_true') | ||
196 | 192 | parser.add_argument('--subscribers-json', action='store') | ||
197 | 193 | parser.add_argument('--true-ages', action='store_true') | ||
198 | 194 | parser.add_argument('--excuses-yaml', action='store') | ||
199 | 195 | parser.add_argument('output') | ||
200 | 196 | args = parser.parse_args() | ||
201 | 197 | |||
202 | 198 | components = args.components.split(',') | ||
203 | 199 | |||
204 | 200 | print("fetching yaml") | ||
205 | 201 | if args.excuses_yaml: | ||
206 | 202 | yaml_text = open(args.excuses_yaml).read() | ||
207 | 203 | else: | ||
208 | 204 | yaml_text = urlopen("https://people.canonical.com/~ubuntu-archive/proposed-migration/update_excuses.yaml").read() | ||
209 | 205 | print("parsing yaml") | ||
210 | 206 | # The CSafeLoader is ten times faster than the regular one | ||
211 | 207 | excuses = yaml.load(yaml_text, Loader=yaml.CSafeLoader) | ||
212 | 208 | |||
213 | 209 | print("pre-processing packages") | ||
214 | 210 | in_proposed_packages = {} | ||
215 | 211 | for item in excuses["sources"]: | ||
216 | 212 | source_package_name = item['item-name'] | ||
217 | 213 | # Missing component means main | ||
218 | 214 | if item.get('component', 'main') not in components: | ||
219 | 215 | continue | ||
220 | 216 | prob = Problem(kind='package-in-proposed', data=item, package_in_proposed=source_package_name) | ||
221 | 217 | in_proposed_packages[source_package_name] = prob | ||
222 | 218 | prob.regressions = [] | ||
223 | 219 | prob.waiting = [] | ||
224 | 220 | if 'autopkgtest' in item['reason']: | ||
225 | 221 | for package, results in sorted(item['policy_info']['autopkgtest'].items()): | ||
226 | 222 | regr_arches = [] | ||
227 | 223 | wait_arches = [] | ||
228 | 224 | for arch, result in sorted(results.items()): | ||
229 | 225 | outcome, log, history, wtf1, wtf2 = result | ||
230 | 226 | if outcome == "REGRESSION": | ||
231 | 227 | regr_arches.append(ArchRegression(arch=arch, log_link=log, hist_link=history)) | ||
232 | 228 | if outcome == "RUNNING": | ||
233 | 229 | wait_arches.append(arch) | ||
234 | 230 | if regr_arches: | ||
235 | 231 | p, v = package.split('/') | ||
236 | 232 | regr = Regression(package=p, version=v, blocking=source_package_name) | ||
237 | 233 | regr.arches = regr_arches | ||
238 | 234 | prob.regressions.append(regr) | ||
239 | 235 | if wait_arches: | ||
240 | 236 | prob.waiting.append((package + ": " + ", ".join(wait_arches))) | ||
241 | 237 | if 'dependencies' in item and 'unsatisfiable-dependencies' in item['dependencies']: | ||
242 | 238 | unsatd = defaultdict(list) | ||
243 | 239 | for arch, packages in item['dependencies']['unsatisfiable-dependencies'].items(): | ||
244 | 240 | for p in packages: | ||
245 | 241 | unsatd[p].append(arch) | ||
246 | 242 | prob.unsatdepends = ['{}: {}'.format(p, ', '.join(sorted(arches))) for p, arches in sorted(unsatd.items())] | ||
247 | 243 | |||
248 | 244 | package_to_problems = defaultdict(list) | ||
249 | 245 | |||
250 | 246 | for problem in in_proposed_packages.values(): | ||
251 | 247 | package_to_problems[problem.package_in_proposed].append(problem) | ||
252 | 248 | for regression in problem.regressions: | ||
253 | 249 | if regression.blocking not in in_proposed_packages: | ||
254 | 250 | continue | ||
255 | 251 | if regression.blocking == regression.package: | ||
256 | 252 | continue | ||
257 | 253 | package_to_problems[regression.package].append(Problem( | ||
258 | 254 | kind='regressing-other', package_in_proposed=regression.blocking, | ||
259 | 255 | regressing_package=regression.package, | ||
260 | 256 | regressions=[regression], | ||
261 | 257 | data=in_proposed_packages[regression.blocking].data)) | ||
262 | 258 | |||
263 | 259 | if args.true_ages: | ||
264 | 260 | true_ages = get_true_ages_in_proposed(set(package_to_problems), 10) | ||
265 | 261 | for package, true_age in true_ages.items(): | ||
266 | 262 | for problem in package_to_problems[package]: | ||
267 | 263 | problem.age = true_age | ||
268 | 264 | |||
269 | 265 | print("getting subscribers") | ||
270 | 266 | if args.subscribers_from_lp: | ||
271 | 267 | subscribers = get_subscribers_lp(set(package_to_problems), 10) | ||
272 | 268 | for p in set(package_to_problems): | ||
273 | 269 | if p not in subscribers: | ||
274 | 270 | subscribers[p] = ['unsubscribed'] | ||
275 | 271 | else: | ||
276 | 272 | subscribers = get_subscribers_json(set(package_to_problems), args.subscribers_json) | ||
277 | 273 | for p in set(package_to_problems): | ||
278 | 274 | if p not in subscribers: | ||
279 | 275 | subscribers[p] = ['unknown'] | ||
280 | 276 | |||
281 | 277 | all_teams = set() | ||
282 | 278 | team_to_problems = defaultdict(list) | ||
283 | 279 | for package, teams in subscribers.items(): | ||
284 | 280 | all_teams |= set(teams) | ||
285 | 281 | for team in teams: | ||
286 | 282 | team_to_problems[team].extend(package_to_problems[package]) | ||
287 | 283 | |||
288 | 284 | for packages in team_to_problems.values(): | ||
289 | 285 | packages.sort(key=lambda prob: (-prob.age, prob.key_package)) | ||
290 | 286 | |||
291 | 287 | team_to_attn_count = {} | ||
292 | 288 | for team, problems in team_to_problems.items(): | ||
293 | 289 | team_to_attn_count[team] = len([problem for problem in problems if problem.late]) | ||
294 | 290 | |||
295 | 291 | print("rendering") | ||
296 | 292 | t = env.get_template('team-report.html') | ||
297 | 293 | with open(args.output, 'w', encoding='utf-8') as fp: | ||
298 | 294 | fp.write(t.render( | ||
299 | 295 | all_teams=all_teams, | ||
300 | 296 | team_to_problems=team_to_problems, | ||
301 | 297 | team_to_attn_count=team_to_attn_count, | ||
302 | 298 | now=excuses["generated-date"].strftime("%Y.%m.%d %H:%M:%S"))) | ||
303 | 299 | |||
304 | 300 | if __name__ == '__main__': | ||
305 | 301 | main() | ||
306 | 0 | 302 | ||
307 | === modified file 'run-proposed-migration' | |||
308 | --- run-proposed-migration 2018-05-01 17:36:23 +0000 | |||
309 | +++ run-proposed-migration 2018-09-11 23:51:09 +0000 | |||
310 | @@ -47,3 +47,8 @@ | |||
311 | 47 | mkdir -p "${logfile%/*}" | 47 | mkdir -p "${logfile%/*}" |
312 | 48 | 48 | ||
313 | 49 | code/b1/britney $actions >"$logfile" 2>&1 | 49 | code/b1/britney $actions >"$logfile" 2>&1 |
314 | 50 | if [ "$SERIES" = "$DEFAULT_SERIES" ]; then | ||
315 | 51 | generate-team-p-m --excuses-yaml $SERIES/update_excuses.yaml \ | ||
316 | 52 | --subscribers-json ~/public_html/package-team-mapping.json \ | ||
317 | 53 | $SERIES/update_excuses_by_team.html | ||
318 | 54 | fi | ||
319 | 50 | 55 | ||
320 | === added directory 'templates' | |||
321 | === added file 'templates/team-report.html' | |||
322 | --- templates/team-report.html 1970-01-01 00:00:00 +0000 | |||
323 | +++ templates/team-report.html 2018-09-11 23:51:09 +0000 | |||
324 | @@ -0,0 +1,107 @@ | |||
325 | 1 | <!DOCTYPE HTML> | ||
326 | 2 | <html> | ||
327 | 3 | <head> | ||
328 | 4 | <meta charset="utf-8"> | ||
329 | 5 | <title>devel-proposed by team</title> | ||
330 | 6 | <style> | ||
331 | 7 | body { font-family: ubuntu } | ||
332 | 8 | .not-late { color: grey; } | ||
333 | 9 | .not-late a { color: #77f; } | ||
334 | 10 | </style> | ||
335 | 11 | </head> | ||
336 | 12 | <body lang="en"> | ||
337 | 13 | <h1>devel-proposed by team</h1> | ||
338 | 14 | <p> | ||
339 | 15 | Generated: {{ now }} | ||
340 | 16 | <ul> | ||
341 | 17 | {% for team in all_teams|sort %} | ||
342 | 18 | <li><a href="#{{ team }}">{{ team }}</a> ({{ ngettext("%(num)d package", "%(num)d packages", team_to_attn_count[team]) }} needing attention)</li> | ||
343 | 19 | {% endfor %} | ||
344 | 20 | </ul> | ||
345 | 21 | {% for team in all_teams|sort %} | ||
346 | 22 | <h1 id="{{ team }}">{{ team }}</h1> | ||
347 | 23 | <p> | ||
348 | 24 | {{ ngettext("%(num)d package", "%(num)d packages", team_to_attn_count[team]) }} needing attention, {{ ngettext("%(num)d package", "%(num)d packages", team_to_problems[team]|count - team_to_attn_count[team]) }} not yet considered late | ||
349 | 25 | </p> | ||
350 | 26 | <ul> | ||
351 | 27 | {% for prob in team_to_problems[team] %} | ||
352 | 28 | {% set d = prob.data %} | ||
353 | 29 | {% set p = prob.package_in_proposed %} | ||
354 | 30 | <li {% if not prob.late %}class="not-late"{% endif %}> | ||
355 | 31 | {% if prob.kind == "regressing-other" %} | ||
356 | 32 | <b>{{ prob.regressing_package }}</b> blocking | ||
357 | 33 | <a href="http://people.canonical.com/~ubuntu-archive/proposed-migration/update_excuses.html#{{ p }}">{{ p }}</a> | ||
358 | 34 | {% else %} | ||
359 | 35 | <a href="http://people.canonical.com/~ubuntu-archive/proposed-migration/update_excuses.html#{{ p }}"><b>{{ p }}</b></a> | ||
360 | 36 | {% endif %} | ||
361 | 37 | {% set urlbase = "https://launchpad.net/ubuntu/+source/" + p + "/" %} | ||
362 | 38 | ({% if d['old-version'] != "-" %}<a href="{{ urlbase }}{{ d["old-version"] }}">{{ d["old-version"] }}{% else %}-{% endif %}</a> | ||
363 | 39 | to | ||
364 | 40 | <a href="{{ urlbase }}{{ d["new-version"] }}">{{ d["new-version"] }}</a>) | ||
365 | 41 | {% if prob.kind == "regressing-other" %} | ||
366 | 42 | for | ||
367 | 43 | {% else %} | ||
368 | 44 | in proposed for | ||
369 | 45 | {% endif %} | ||
370 | 46 | {{ ngettext("%(num)d day", "%(num)d days", prob.age|int) }} | ||
371 | 47 | <ul> | ||
372 | 48 | {% if prob.waiting %} | ||
373 | 49 | <li> | ||
374 | 50 | Waiting | ||
375 | 51 | <ul> | ||
376 | 52 | {% for p in prob.waiting %} | ||
377 | 53 | <li>{{ p }}</li> | ||
378 | 54 | {% endfor %} | ||
379 | 55 | </ul> | ||
380 | 56 | </li> | ||
381 | 57 | {% endif %} | ||
382 | 58 | {% if d.get("missing-builds") %} | ||
383 | 59 | <li>Missing builds, see excuses</li> | ||
384 | 60 | {% endif %} | ||
385 | 61 | {% if 'no-binaries' in d["reason"] %} | ||
386 | 62 | <li>No binaries</li> | ||
387 | 63 | {% endif %} | ||
388 | 64 | {% if prob.regressions %} | ||
389 | 65 | <li> | ||
390 | 66 | Regressions | ||
391 | 67 | <ul> | ||
392 | 68 | {% for regr in prob.regressions %} | ||
393 | 69 | <li> | ||
394 | 70 | {{ regr.package_version }}: | ||
395 | 71 | {% for arch_regr in regr.arches %} | ||
396 | 72 | {{ arch_regr.arch }} (<a href="{{ arch_regr.log_link }}">log</a>, <a href="{{ arch_regr.hist_link }}">history</a>) | ||
397 | 73 | {%- if not loop.last %}, {% endif %} | ||
398 | 74 | {% endfor %} | ||
399 | 75 | </li> | ||
400 | 76 | {% endfor %} | ||
401 | 77 | </ul> | ||
402 | 78 | </li> | ||
403 | 79 | {% endif %} | ||
404 | 80 | {% if prob.kind != "regressing-other" %} | ||
405 | 81 | {% if d.get('dependencies', {}).get('blocked-by', []) %} | ||
406 | 82 | <li>Depends on {{ d['dependencies']['blocked-by']|join(", ") }}</li> | ||
407 | 83 | {% endif %} | ||
408 | 84 | {% if prob.unsatdepends %} | ||
409 | 85 | <li> | ||
410 | 86 | Unsatisfiable depends: | ||
411 | 87 | <ul> | ||
412 | 88 | {% for unsatd in prob.unsatdepends %} | ||
413 | 89 | <li>{{ unsatd }}</li> | ||
414 | 90 | {% endfor %} | ||
415 | 91 | </ul> | ||
416 | 92 | </li> | ||
417 | 93 | {% endif %} | ||
418 | 94 | {% if d["policy_info"]["block-bugs"] %} | ||
419 | 95 | <li>Blocked by bug: {{ d["policy_info"]["block-bugs"]|map("int")|sort|map("string")|join(", ") }}</li> | ||
420 | 96 | {% endif %} | ||
421 | 97 | {% if d["is-candidate"] %} | ||
422 | 98 | <li><b>candidate</b></li> | ||
423 | 99 | {% endif %} | ||
424 | 100 | {% endif %} | ||
425 | 101 | </ul> | ||
426 | 102 | </li> | ||
427 | 103 | {% endfor %} | ||
428 | 104 | </ul> | ||
429 | 105 | {% endfor %} | ||
430 | 106 | </body> | ||
431 | 107 | </html> |