Merge ~racb/git-ubuntu:interleave-publication-dates into git-ubuntu:master
- Git
- lp:~racb/git-ubuntu
- interleave-publication-dates
- Merge into master
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 7a70527cca95da0ef5f2d8aaf565e6441a663558 | ||||
Proposed branch: | ~racb/git-ubuntu:interleave-publication-dates | ||||
Merge into: | git-ubuntu:master | ||||
Diff against target: |
447 lines (+300/-51) 4 files modified
gitubuntu/git_repository.py (+7/-0) gitubuntu/importer.py (+52/-44) gitubuntu/source_information.py (+117/-7) gitubuntu/source_information_test.py (+124/-0) |
||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Server Team CI bot | continuous-integration | Approve | |
Bryce Harrington | Approve | ||
Review via email: mp+382510@code.launchpad.net |
Commit message
Make Jenkins happy
Description of the change
Server Team CI bot (server-team-bot) wrote : | # |
Bryce Harrington (bryce) wrote : | # |
√ python3 setup.py build
√ python3 setup.py check
√ ./snap-
√ PASS (syntax) source-
√ PASS (compilation) source-
√ PASS (syntax) import-
√ PASS (compilation) import-
√ PASS (syntax) update-
√ PASS (compilation) update-
√ 335 passed, 3 xfailed in 183.72 seconds
√ Coverage for importer.py at 64%
√ Overall coverage 54%
√ Per-patch code review
√ d87a1adecd324cd
+ Makes routine automagically determine target branch
+ LGTM
√ ade437105ceece9
+ importppa.py also has a couple launchpad_
calls, however this is slated for removal soon. Otherwise, these
calls would need adjusted similarly to importer.py.
+ Otherwise LGTM
√ 371c75514e53ec4
+ docstrings always welcome
+ LGTM
√ feb96661189ea0c
+ Refactors handling of active_series_only to pre-loop condition as
a constructed dist_priority dictionary.
+ Adds interleave_
the debian and ubuntu versions.
+ Adds testcase for same.
+ This routine is used to build a list of source publications
(spi's), which are sorted by heapq.merge() using a custom key
constructed by lookup of date created from launchpad, and dist_priority.
+ Studied dict comprehension for dist_priority creation. Looks
correct.
+ Studied _get_cached_
+ interleave_
value documented,
rtype: iterator
returns: iterator over the sorted values
+ fake_guspi() doesn't have return documented -- but it's internal to
a test case, so I don't think it really matters. It's obvious
enough what it's doing.
+ fake_guspi() documents 'Any' for param type, which appears to mean
the function's parameters are not type specific. So could specify
either a datetime object or, as in this case, integers.
Robie Basak (racb) wrote : | # |
Thank you for the review!
On Tue, Apr 21, 2020 at 01:08:49AM -0000, Bryce Harrington wrote:
> + importppa.py also has a couple launchpad_
> calls, however this is slated for removal soon. Otherwise, these
> calls would need adjusted similarly to importer.py.
Good spot! Yes - I checked the other callers but as they all turned out
to be slated for removal I ignored them.
> + interleave_
> value documented,
> rtype: iterator
> returns: iterator over the sorted values
Added, thanks.
> + fake_guspi() doesn't have return documented -- but it's internal to
> a test case, so I don't think it really matters. It's obvious
> enough what it's doing.
I added it anyway, thanks.
> + fake_guspi() documents 'Any' for param type, which appears to mean
> the function's parameters are not type specific. So could specify
> either a datetime object or, as in this case, integers.
Right - to keep things simple I took advantage of duck typing and used
integers instead of datetime objects. It only matters that they are
compared for sorting purposes.
I've forced pushed an update that only adjusts the final commit for
comments and docstrings, including a couple of additional minor
corrections to comments and docstrings that I don't think warrant
further review.
I will merge this branch. Thanks again!
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:7a70527cca9
https:/
Executed test runs:
SUCCESS: VM Setup
SUCCESS: Build
SUCCESS: Unit Tests
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild:
https:/
Preview Diff
1 | diff --git a/gitubuntu/git_repository.py b/gitubuntu/git_repository.py |
2 | index ce7ebe8..6673878 100644 |
3 | --- a/gitubuntu/git_repository.py |
4 | +++ b/gitubuntu/git_repository.py |
5 | @@ -1669,6 +1669,13 @@ class GitUbuntuRepository: |
6 | """Extract the last version in debian/changelog of all |
7 | '<namespace>/<head_prefix>/debian/*' and |
8 | '<namespace>/<head_prefix>/ubuntu/*' branches. |
9 | + |
10 | + :rtype: dict |
11 | + :returns: a dictionary keyed by branch name (without a 'refs/heads/' |
12 | + prefix). The value is a dictionary with two keys: 'head' and |
13 | + 'version'. The 'head' value is a pygit2.Branch object. The |
14 | + 'version' value is the package version string found at that branch |
15 | + head. |
16 | """ |
17 | versions = dict() |
18 | errors = False |
19 | diff --git a/gitubuntu/importer.py b/gitubuntu/importer.py |
20 | index 604b478..12d09cb 100644 |
21 | --- a/gitubuntu/importer.py |
22 | +++ b/gitubuntu/importer.py |
23 | @@ -289,7 +289,7 @@ def _main_with_repo( |
24 | |
25 | parent_overrides = parse_parentfile(parentfile, pkgname) |
26 | |
27 | - only_debian, history_found = import_publishes( |
28 | + history_found = import_publishes( |
29 | repo=repo, |
30 | pkgname=pkgname, |
31 | namespace=namespace, |
32 | @@ -377,14 +377,17 @@ def _main_with_repo( |
33 | # Changelog notes go to refs/notes/commits due to LP: #1871838 |
34 | 'refs/notes/%s/changelog:refs/notes/commits' % namespace, |
35 | ]) |
36 | - # update our reference |
37 | + # Update HEAD on LP to point to our desired default; this is |
38 | + # ubuntu/devel if that exists, or debian/sid otherwise. |
39 | + if ('refs/heads/%s/ubuntu/devel' % namespace) in repo.references: |
40 | + desired_lp_default_branch = 'refs/heads/ubuntu/devel' |
41 | + else: |
42 | + desired_lp_default_branch = 'refs/heads/debian/sid' |
43 | lp_git_repo = lp.git_repositories.getByPath(path=repo_path) |
44 | for i in range(retries): |
45 | try: |
46 | - if only_debian: |
47 | - lp_git_repo.default_branch = 'refs/heads/debian/sid' |
48 | - else: |
49 | - lp_git_repo.default_branch = 'refs/heads/ubuntu/devel' |
50 | + logging.debug('Setting LP HEAD to %s' % desired_lp_default_branch) |
51 | + lp_git_repo.default_branch = desired_lp_default_branch |
52 | lp_git_repo.lp_save() |
53 | break |
54 | except (NotFound, PreconditionFailed) as e: |
55 | @@ -1995,7 +1998,6 @@ def import_publishes( |
56 | parent_overrides, |
57 | ): |
58 | history_found = False |
59 | - only_debian = False |
60 | srcpkg_information = None |
61 | if patches_applied: |
62 | _namespace = namespace |
63 | @@ -2012,46 +2014,52 @@ def import_publishes( |
64 | import_unapplied_spi, |
65 | skip_orig=skip_orig, |
66 | ) |
67 | - for distname, versions, dist_sinfo in ( |
68 | - ("debian", debian_head_versions, debian_sinfo), |
69 | - ("ubuntu", ubuntu_head_versions, ubuntu_sinfo)): |
70 | - if active_series_only and distname == "debian": |
71 | - continue |
72 | - try: |
73 | - for srcpkg_information in dist_sinfo.launchpad_versions_published_after( |
74 | - versions, |
75 | - namespace, |
76 | - workdir=workdir, |
77 | - active_series_only=active_series_only |
78 | - ): |
79 | - history_found = True |
80 | - import_func( |
81 | - repo=repo, |
82 | - spi=srcpkg_information, |
83 | - namespace=_namespace, |
84 | - parent_overrides=parent_overrides, |
85 | - ) |
86 | - except NoPublicationHistoryException: |
87 | - logging.warning("No publication history found for %s in %s.", |
88 | - pkgname, distname |
89 | + # Always look in Ubuntu for new publications |
90 | + gusi_head_versions_tuple_list = [ |
91 | + (ubuntu_sinfo, ubuntu_head_versions), |
92 | + ] |
93 | + # Unless --active-series-only was specified, also look in Debian for new |
94 | + # publications. |
95 | + if not active_series_only: |
96 | + # For hash stability, we must process new Debian publications with the |
97 | + # same date_created before new Ubuntu publications, so we insert the |
98 | + # Debian entry before the Ubuntu entry. |
99 | + gusi_head_versions_tuple_list.insert( |
100 | + 0, |
101 | + (debian_sinfo, debian_head_versions), |
102 | + ) |
103 | + try: |
104 | + # We must process new publications in order of date_created interleaved |
105 | + # across both distributions (Debian first) in order to maintain hash |
106 | + # stability according to the spec. |
107 | + for srcpkg_information in GitUbuntuSourceInformation.interleave_launchpad_versions_published_after( |
108 | + gusi_head_versions_tuple_list, |
109 | + namespace=namespace, |
110 | + workdir=workdir, |
111 | + active_series_only=active_series_only, |
112 | + ): |
113 | + history_found = True |
114 | + import_func( |
115 | + repo=repo, |
116 | + spi=srcpkg_information, |
117 | + namespace=_namespace, |
118 | + parent_overrides=parent_overrides, |
119 | ) |
120 | - if distname == 'ubuntu': |
121 | - only_debian = True |
122 | - except Exception as e: |
123 | - if srcpkg_information is None: |
124 | - msg = 'Unable to import %s to %s' % (import_type, distname) |
125 | - else: |
126 | - msg = 'Unable to import %s %s to %s' % (import_type, |
127 | - str(srcpkg_information.version), distname) |
128 | - if not patches_applied: |
129 | - raise GitUbuntuImportError(msg) from e |
130 | - else: |
131 | - logging.error(msg) |
132 | - logging.error(traceback.format_exc()) |
133 | + except Exception as e: |
134 | + if srcpkg_information is None: |
135 | + msg = 'Unable to import %s' % import_type |
136 | else: |
137 | - history_found = True |
138 | + msg = 'Unable to import %s %s' % (import_type, |
139 | + str(srcpkg_information.version)) |
140 | + if not patches_applied: |
141 | + raise GitUbuntuImportError(msg) from e |
142 | + else: |
143 | + logging.error(msg) |
144 | + logging.error(traceback.format_exc()) |
145 | + else: |
146 | + history_found = True |
147 | |
148 | - return (only_debian, history_found) |
149 | + return history_found |
150 | |
151 | |
152 | def parse_args(subparsers=None, base_subparsers=None): |
153 | diff --git a/gitubuntu/source_information.py b/gitubuntu/source_information.py |
154 | index 38df33e..cf729b8 100644 |
155 | --- a/gitubuntu/source_information.py |
156 | +++ b/gitubuntu/source_information.py |
157 | @@ -1,4 +1,5 @@ |
158 | import functools |
159 | +import heapq |
160 | import logging |
161 | import os |
162 | import re |
163 | @@ -544,12 +545,38 @@ class GitUbuntuSourceInformation(object): |
164 | for srcpkg in spph: |
165 | yield self.get_corrected_spi(srcpkg, workdir) |
166 | |
167 | - def launchpad_versions_published_after(self, head_versions, namespace, workdir=None, active_series_only=False): |
168 | + def launchpad_versions_published_after( |
169 | + self, |
170 | + head_versions, |
171 | + namespace, |
172 | + workdir=None, |
173 | + active_series_only=False, |
174 | + ): |
175 | + """Return a sequence of GitUbuntuSourcePackageInformation instances |
176 | + |
177 | + Return a sequence of GitUbuntuSourcePackageInformation instances |
178 | + representing Launchpad publications created in a Launchpad distribution |
179 | + after a particular point, as determined by the head_versions parameter. |
180 | + |
181 | + :param dict head_versions: as returned by |
182 | + GitUbuntuRepository.get_heads_and_versions(). This may be empty, in |
183 | + which case all publications are returned. Otherwise, publications |
184 | + are skipped that match a version already found in head_versions, or |
185 | + with a date_created older than what is found in head_versions, is |
186 | + skipped, as well as any publications that have a prior date_created |
187 | + date. |
188 | + :param str namespace: the namespace prefix used in the git repository. |
189 | + :param workdir: passed through to the GitUbuntuSourcePackageInformation |
190 | + constructor. |
191 | + :param bool active_series_only: for Ubuntu, skip any series that is not |
192 | + an active series, as determined by self.active_series_name_list. |
193 | + :rtype: sequence(GitUbuntuSourcePackageInformation) |
194 | + """ |
195 | args = { |
196 | - 'exact_match':True, |
197 | - 'source_name':self.pkgname, |
198 | - 'order_by_date':True, |
199 | - } |
200 | + 'exact_match': True, |
201 | + 'source_name': self.pkgname, |
202 | + 'order_by_date': True, |
203 | + } |
204 | |
205 | # we have the date of the commit too, so we can double-check |
206 | # that it matches |
207 | @@ -570,8 +597,10 @@ class GitUbuntuSourceInformation(object): |
208 | # Sanity check that the passed in srcpkg name has a publication |
209 | # history |
210 | if len(spph) == 0: |
211 | - raise NoPublicationHistoryException("Is %s published in %s?" % |
212 | - (self.pkgname, self.dist_name)) |
213 | + logging.warning("No publication history found for %s in %s.", |
214 | + self.pkgname, self.dist_name |
215 | + ) |
216 | + return |
217 | if len(head_versions) > 0: |
218 | _spph = list() |
219 | for spphr in spph: |
220 | @@ -610,6 +639,87 @@ class GitUbuntuSourceInformation(object): |
221 | yield spi |
222 | raise StopIteration() |
223 | |
224 | + @staticmethod |
225 | + def interleave_launchpad_versions_published_after( |
226 | + gusi_head_versions_tuple_list, |
227 | + namespace, |
228 | + workdir=None, |
229 | + active_series_only=False, |
230 | + ): |
231 | + """Interleave multiple calls to launchpad_versions_published_after |
232 | + |
233 | + Interleave the result of multiple calls to the |
234 | + launchpad_versions_published_after() method. The specification requires |
235 | + new publications to be processed across the Debian and Ubuntu |
236 | + distributions in order of date_created to establish |
237 | + hash stability. launchpad_versions_published_after() returns the result |
238 | + of one distribution at a time. This method calls any number |
239 | + simultaneously and interleaves the results so that the caller sees a |
240 | + single combined sequence in ascending order of date_created. If |
241 | + date_created is the same, results are provided in the order that the |
242 | + distributions are specified in gusi_head_versions_tuple_list. In other |
243 | + words, the distribution ordering is the secondary sort key. |
244 | + |
245 | + :param list(tuple(GitUbuntuSourceInformation, dict)) |
246 | + gusi_head_versions_tuple_list: a list of the parameters to use for |
247 | + the underlying launchpad_versions_published_after() method call. |
248 | + The GitUbuntuSourceInformation instance is the object to call the |
249 | + method against. The second element of the tuple is the |
250 | + head_versions parameter to pass to that method. The other |
251 | + parameters are passed through to the underlying call as-is. |
252 | + :param namespace: passed through to |
253 | + launchpad_versions_published_after(). |
254 | + :param workdir: passed through to launchpad_versions_published_after(). |
255 | + :param bool active_series_only: passed through to |
256 | + launchpad_versions_published_after(). |
257 | + :rtype: sequence(GitUbuntuSourcePackageInformation) |
258 | + :returns: the combined return sequences of the underlying calls to |
259 | + launchpad_versions_published_after() interleaved as specified. |
260 | + """ |
261 | + # Create a mapping of distribution to priority, where distribution is |
262 | + # the Launchpad distribution_link string representing the distribution, |
263 | + # and priority is an integer (low number=high priority). Then we will |
264 | + # be able to sort according to priority as a secondary key to ensure |
265 | + # that if timestamps of a publication are the same across |
266 | + # distributions, the distribution mentioned first in |
267 | + # gusi_head_versions_tuple_list will appear in the results first. |
268 | + dist_priority = { |
269 | + gusi.dist.self_link: i |
270 | + for i, (gusi, head_version) |
271 | + in enumerate(gusi_head_versions_tuple_list) |
272 | + } |
273 | + |
274 | + # Now that we have a mapping of distribution_link to priority, the sort |
275 | + # key function is simple: sort on date created first, and if they are |
276 | + # equal then use the distribution priority. Python will do this for us |
277 | + # if we provide a tuple. The distribution priority is necessary because |
278 | + # there is no guarantee that heapq.merge(), as used below, provides a |
279 | + # stable sort. If it did, then keying on date_created only would be |
280 | + # sufficient. |
281 | + def key_func(spi): |
282 | + distro_series = _get_cached_lp_link(spi._spphr, 'distro_series') |
283 | + distribution_link = distro_series.distribution_link |
284 | + return spi._spphr.date_created, dist_priority[distribution_link] |
285 | + |
286 | + # Create one generator per gusi_head_versions_tuple_list entry. |
287 | + spi_generators_to_interleave = [ |
288 | + gusi.launchpad_versions_published_after( |
289 | + head_versions=head_versions, |
290 | + namespace=namespace, |
291 | + workdir=workdir, |
292 | + active_series_only=active_series_only, |
293 | + ) |
294 | + for gusi, head_versions in gusi_head_versions_tuple_list |
295 | + ] |
296 | + |
297 | + # heapq.merge() will now do the interleaving. This relies on the |
298 | + # results of each of the calls to launchpad_versions_published_after() |
299 | + # already being sorted by date_created. |
300 | + return heapq.merge( |
301 | + *spi_generators_to_interleave, |
302 | + key=key_func, |
303 | + ) |
304 | + |
305 | def parse_pullfile(self, pullfile): |
306 | """Extract source file overrides from a file |
307 | |
308 | diff --git a/gitubuntu/source_information_test.py b/gitubuntu/source_information_test.py |
309 | index 7aacb1a..7e88d77 100644 |
310 | --- a/gitubuntu/source_information_test.py |
311 | +++ b/gitubuntu/source_information_test.py |
312 | @@ -1,7 +1,10 @@ |
313 | import collections |
314 | +import functools |
315 | +from unittest.mock import Mock, sentinel |
316 | |
317 | import keyring |
318 | import pytest |
319 | +import gitubuntu.source_information as target |
320 | from gitubuntu.source_information import derive_codename_from_series |
321 | from gitubuntu import source_information |
322 | |
323 | @@ -150,3 +153,124 @@ def test_derive_codename_from_broken_distro_info(monkeypatch, series, codename): |
324 | monkeypatch.setattr(source_information, "_ddi", MockDebianDistroInfo()) |
325 | |
326 | assert derive_codename_from_series(series) == codename |
327 | + |
328 | + |
329 | +@pytest.mark.parametrize(['debian_input', 'ubuntu_input', 'expected'], [ |
330 | + ( |
331 | + [], |
332 | + [], |
333 | + [], |
334 | + ), |
335 | + ( |
336 | + [0], |
337 | + [0], |
338 | + [(0, 'debian'), (0, 'ubuntu')], |
339 | + ), |
340 | + ( |
341 | + [1], |
342 | + [0], |
343 | + [(0, 'ubuntu'), (1, 'debian')], |
344 | + ), |
345 | +]) |
346 | +def test_interleave_launchpad_versions_published_after( |
347 | + debian_input, |
348 | + ubuntu_input, |
349 | + expected, |
350 | +): |
351 | + """Test that interleaving works correctly |
352 | + |
353 | + We will always ask for Debian before Ubuntu. When date_created is the same, |
354 | + Debian should come first. If date_created is different, the earlier |
355 | + date_created should come first. |
356 | + |
357 | + :param list(Any) debian_input: the date_created attributes of the fake |
358 | + Launchpad source_package_publishing_history objects of the fake |
359 | + GitUbuntuSourcePackageInformation objects that will be returned by the |
360 | + faked call to launchpad_versions_published_after() call, for the Debian |
361 | + distribution. |
362 | + :param list(Any) debian_input: the date_created attributes of the fake |
363 | + Launchpad source_package_publishing_history objects of the fake |
364 | + GitUbuntuSourcePackageInformation objects that will be returned by the |
365 | + faked call to launchpad_versions_published_after() call, for the Ubuntu |
366 | + distribution. |
367 | + :param list(tuple(date_created, distribution)): the expected ordering in |
368 | + the return sequence of the |
369 | + interleave_launchpad_versions_published_after() call. date_created |
370 | + corresponds to the input data from debian_input and ubuntu_input. |
371 | + distribution is either the 'debian' string or the 'ubuntu' string |
372 | + depending on whether the return value was supposed to come from the |
373 | + Debian or Ubuntu fake GitUbuntuSourceInformation object. |
374 | + """ |
375 | + # Store all fake guspis created, since equivalent guspis will be asserted |
376 | + # for equality later, and Mock objects that are otherwise identical do not |
377 | + # compare equal if they are separate instances. For the same call to |
378 | + # fake_guspi() below, we want to return the same Mock object instance so |
379 | + # that they do compare equal. |
380 | + fake_guspis = {} |
381 | + def fake_guspi(dist_link, date_created): |
382 | + """Return a fake target.GitUbuntuSourcePackageInformation object |
383 | + |
384 | + Multiple calls to this function with the same parameters return the |
385 | + same fake objects so that they compare equal. |
386 | + |
387 | + :param Any dist_link: a mock distribution_link that can be compared |
388 | + against later (for example, use a unittest.mock.sentinel). |
389 | + :param Any date_created: the fake date_created attribute of the |
390 | + underlying Launchpad source_package_publishing_history object. |
391 | + :rtype: Mock |
392 | + :returns: the fake target.GitUbuntuSourcePackageInformation object. |
393 | + """ |
394 | + try: |
395 | + return fake_guspis[(dist_link, date_created)] |
396 | + except KeyError: |
397 | + result = Mock( |
398 | + spec=target.GitUbuntuSourcePackageInformation, |
399 | + _spphr=Mock( |
400 | + distro_series_link=str(hash(dist_link)) + '_distro_series', |
401 | + distro_series=Mock(distribution_link=dist_link), |
402 | + date_created=date_created, |
403 | + ) |
404 | + ) |
405 | + fake_guspis[(dist_link, date_created)] = result |
406 | + return result |
407 | + |
408 | + # A fake target.GitUbuntuSourceInformation object to represent the Debian |
409 | + # distribution. |
410 | + debian = Mock( |
411 | + spec=target.GitUbuntuSourceInformation, |
412 | + dist=Mock(self_link=sentinel.debian_dist_link), |
413 | + launchpad_versions_published_after=Mock(return_value=[ |
414 | + fake_guspi(sentinel.debian_dist_link, date_created) |
415 | + for date_created in debian_input |
416 | + ]) |
417 | + ) |
418 | + # A fake target.GitUbuntuSourceInformation object to represent the Ubuntu |
419 | + # distribution. |
420 | + ubuntu = Mock( |
421 | + spec=target.GitUbuntuSourceInformation, |
422 | + dist=Mock(self_link=sentinel.ubuntu_dist_link), |
423 | + launchpad_versions_published_after=Mock(return_value=[ |
424 | + fake_guspi(sentinel.ubuntu_dist_link, date_created) |
425 | + for date_created in ubuntu_input |
426 | + ]) |
427 | + ) |
428 | + |
429 | + gusi_head_versions_tuple_list = [ |
430 | + (debian, None), |
431 | + (ubuntu, None), |
432 | + ] |
433 | + result = target.GitUbuntuSourceInformation.interleave_launchpad_versions_published_after( |
434 | + gusi_head_versions_tuple_list=gusi_head_versions_tuple_list, |
435 | + namespace=None, |
436 | + ) |
437 | + expanded_result = list(result) |
438 | + assert expanded_result == [ |
439 | + fake_guspi( |
440 | + dist_link={ |
441 | + 'debian': sentinel.debian_dist_link, |
442 | + 'ubuntu': sentinel.ubuntu_dist_link, |
443 | + }[dist], |
444 | + date_created=date_created, |
445 | + ) |
446 | + for date_created, dist in expected |
447 | + ] |
PASSED: Continuous integration, rev:feb96661189 ea0cef2910a6faa d1bf918ef57ec9 /jenkins. ubuntu. com/server/ job/git- ubuntu- ci/488/
https:/
Executed test runs:
SUCCESS: VM Setup
SUCCESS: Build
SUCCESS: Unit Tests
IN_PROGRESS: Declarative: Post Actions
Click here to trigger a rebuild: /jenkins. ubuntu. com/server/ job/git- ubuntu- ci/488/ /rebuild
https:/