Merge ~cjwatson/launchpad:ci-build-basic-views into launchpad:master
- Git
- lp:~cjwatson/launchpad
- ci-build-basic-views
- Merge into master
Proposed by
Colin Watson
Status: | Merged |
---|---|
Approved by: | Colin Watson |
Approved revision: | 7a94e083c202d8133620c7231bc658bc871ac495 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~cjwatson/launchpad:ci-build-basic-views |
Merge into: | launchpad:master |
Diff against target: |
745 lines (+645/-0) 9 files modified
lib/lp/code/browser/cibuild.py (+148/-0) lib/lp/code/browser/configure.zcml (+38/-0) lib/lp/code/browser/gitrepository.py (+9/-0) lib/lp/code/browser/tests/test_cibuild.py (+202/-0) lib/lp/code/interfaces/cibuild.py (+13/-0) lib/lp/code/model/cibuild.py (+14/-0) lib/lp/code/model/tests/test_cibuild.py (+12/-0) lib/lp/code/templates/cibuild-index.pt (+181/-0) lib/lp/code/templates/cibuild-retry.pt (+28/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ioana Lasc (community) | Approve | ||
Review via email: mp+417630@code.launchpad.net |
Commit message
Add basic index/retry/
Description of the change
This should be just enough to let us deal with build farm administration as it relates to CI builds.
To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/code/browser/cibuild.py b/lib/lp/code/browser/cibuild.py |
2 | new file mode 100644 |
3 | index 0000000..e03d7b7 |
4 | --- /dev/null |
5 | +++ b/lib/lp/code/browser/cibuild.py |
6 | @@ -0,0 +1,148 @@ |
7 | +# Copyright 2022 Canonical Ltd. This software is licensed under the |
8 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
9 | + |
10 | +"""CI build views.""" |
11 | + |
12 | +__all__ = [ |
13 | + "CIBuildContextMenu", |
14 | + "CIBuildNavigation", |
15 | + "CIBuildView", |
16 | + ] |
17 | + |
18 | +from zope.interface import Interface |
19 | + |
20 | +from lp.app.browser.launchpadform import ( |
21 | + action, |
22 | + LaunchpadFormView, |
23 | + ) |
24 | +from lp.code.interfaces.cibuild import ICIBuild |
25 | +from lp.services.librarian.browser import FileNavigationMixin |
26 | +from lp.services.webapp import ( |
27 | + canonical_url, |
28 | + ContextMenu, |
29 | + enabled_with_permission, |
30 | + Link, |
31 | + Navigation, |
32 | + ) |
33 | +from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm |
34 | + |
35 | + |
36 | +class CIBuildNavigation(Navigation, FileNavigationMixin): |
37 | + usedfor = ICIBuild |
38 | + |
39 | + |
40 | +class CIBuildContextMenu(ContextMenu): |
41 | + """Context menu for CI builds.""" |
42 | + |
43 | + usedfor = ICIBuild |
44 | + |
45 | + facet = "overview" |
46 | + |
47 | + links = ("retry", "cancel", "rescore") |
48 | + |
49 | + @enabled_with_permission("launchpad.Edit") |
50 | + def retry(self): |
51 | + return Link( |
52 | + "+retry", "Retry this build", icon="retry", |
53 | + enabled=self.context.can_be_retried) |
54 | + |
55 | + @enabled_with_permission("launchpad.Edit") |
56 | + def cancel(self): |
57 | + return Link( |
58 | + "+cancel", "Cancel build", icon="remove", |
59 | + enabled=self.context.can_be_cancelled) |
60 | + |
61 | + @enabled_with_permission("launchpad.Admin") |
62 | + def rescore(self): |
63 | + return Link( |
64 | + "+rescore", "Rescore build", icon="edit", |
65 | + enabled=self.context.can_be_rescored) |
66 | + |
67 | + |
68 | +class CIBuildView(LaunchpadFormView): |
69 | + """Default view of a CI build.""" |
70 | + |
71 | + class schema(Interface): |
72 | + """Schema for a build.""" |
73 | + |
74 | + @property |
75 | + def label(self): |
76 | + return self.context.title |
77 | + |
78 | + page_title = label |
79 | + |
80 | + |
81 | +class CIBuildRetryView(LaunchpadFormView): |
82 | + """View for retrying a CI build.""" |
83 | + |
84 | + class schema(Interface): |
85 | + """Schema for retrying a build.""" |
86 | + |
87 | + page_title = label = "Retry build" |
88 | + |
89 | + @property |
90 | + def cancel_url(self): |
91 | + return canonical_url(self.context) |
92 | + next_url = cancel_url |
93 | + |
94 | + @action("Retry build", name="retry") |
95 | + def request_action(self, action, data): |
96 | + """Retry the build.""" |
97 | + if not self.context.can_be_retried: |
98 | + self.request.response.addErrorNotification( |
99 | + "Build cannot be retried") |
100 | + else: |
101 | + self.context.retry() |
102 | + self.request.response.addInfoNotification("Build has been queued") |
103 | + |
104 | + self.request.response.redirect(self.next_url) |
105 | + |
106 | + |
107 | +class CIBuildCancelView(LaunchpadFormView): |
108 | + """View for cancelling a CI build.""" |
109 | + |
110 | + class schema(Interface): |
111 | + """Schema for cancelling a build.""" |
112 | + |
113 | + page_title = label = "Cancel build" |
114 | + |
115 | + @property |
116 | + def cancel_url(self): |
117 | + return canonical_url(self.context) |
118 | + next_url = cancel_url |
119 | + |
120 | + @action("Cancel build", name="cancel") |
121 | + def request_action(self, action, data): |
122 | + """Cancel the build.""" |
123 | + self.context.cancel() |
124 | + |
125 | + |
126 | +class CIBuildRescoreView(LaunchpadFormView): |
127 | + """View for rescoring a CI build.""" |
128 | + |
129 | + schema = IBuildRescoreForm |
130 | + |
131 | + page_title = label = "Rescore build" |
132 | + |
133 | + def __call__(self): |
134 | + if self.context.can_be_rescored: |
135 | + return super().__call__() |
136 | + self.request.response.addWarningNotification( |
137 | + "Cannot rescore this build because it is not queued.") |
138 | + self.request.response.redirect(canonical_url(self.context)) |
139 | + |
140 | + @property |
141 | + def cancel_url(self): |
142 | + return canonical_url(self.context) |
143 | + next_url = cancel_url |
144 | + |
145 | + @action("Rescore build", name="rescore") |
146 | + def request_action(self, action, data): |
147 | + """Rescore the build.""" |
148 | + score = data.get("priority") |
149 | + self.context.rescore(score) |
150 | + self.request.response.addNotification("Build rescored to %s." % score) |
151 | + |
152 | + @property |
153 | + def initial_values(self): |
154 | + return {"score": str(self.context.buildqueue_record.lastscore)} |
155 | diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml |
156 | index 5520698..3f16bf4 100644 |
157 | --- a/lib/lp/code/browser/configure.zcml |
158 | +++ b/lib/lp/code/browser/configure.zcml |
159 | @@ -1422,6 +1422,44 @@ |
160 | for="lp.code.interfaces.cibuild.ICIBuild" |
161 | path_expression="string:+build/${id}" |
162 | attribute_to_parent="git_repository"/> |
163 | + <browser:menus |
164 | + module="lp.code.browser.cibuild" |
165 | + classes="CIBuildContextMenu" /> |
166 | + <browser:navigation |
167 | + module="lp.code.browser.cibuild" |
168 | + classes="CIBuildNavigation" /> |
169 | + <browser:defaultView |
170 | + for="lp.code.interfaces.cibuild.ICIBuild" |
171 | + name="+index" /> |
172 | + <browser:page |
173 | + for="lp.code.interfaces.cibuild.ICIBuild" |
174 | + class="lp.code.browser.cibuild.CIBuildView" |
175 | + permission="launchpad.View" |
176 | + name="+index" |
177 | + template="../templates/cibuild-index.pt" /> |
178 | + <browser:page |
179 | + for="lp.code.interfaces.cibuild.ICIBuild" |
180 | + class="lp.code.browser.cibuild.CIBuildRetryView" |
181 | + permission="launchpad.Edit" |
182 | + name="+retry" |
183 | + template="../templates/cibuild-retry.pt" /> |
184 | + <browser:page |
185 | + for="lp.code.interfaces.cibuild.ICIBuild" |
186 | + class="lp.code.browser.cibuild.CIBuildCancelView" |
187 | + permission="launchpad.Edit" |
188 | + name="+cancel" |
189 | + template="../../app/templates/generic-edit.pt" /> |
190 | + <browser:page |
191 | + for="lp.code.interfaces.cibuild.ICIBuild" |
192 | + class="lp.code.browser.cibuild.CIBuildRescoreView" |
193 | + permission="launchpad.Admin" |
194 | + name="+rescore" |
195 | + template="../../app/templates/generic-edit.pt" /> |
196 | + <adapter |
197 | + provides="lp.services.webapp.interfaces.IBreadcrumb" |
198 | + for="lp.code.interfaces.cibuild.ICIBuild" |
199 | + factory="lp.services.webapp.breadcrumb.TitleBreadcrumb" |
200 | + permission="zope.Public" /> |
201 | |
202 | </facet> |
203 | |
204 | diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py |
205 | index f0b6668..072f626 100644 |
206 | --- a/lib/lp/code/browser/gitrepository.py |
207 | +++ b/lib/lp/code/browser/gitrepository.py |
208 | @@ -99,6 +99,7 @@ from lp.code.errors import ( |
209 | GitRepositoryExists, |
210 | GitTargetError, |
211 | ) |
212 | +from lp.code.interfaces.cibuild import ICIBuildSet |
213 | from lp.code.interfaces.gitnamespace import get_git_namespace |
214 | from lp.code.interfaces.gitref import IGitRefBatchNavigator |
215 | from lp.code.interfaces.gitrepository import ( |
216 | @@ -145,6 +146,7 @@ from lp.services.webapp.publisher import DataDownloadView |
217 | from lp.services.webapp.snapshot import notify_modified |
218 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin |
219 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin |
220 | +from lp.soyuz.browser.build import get_build_by_id_str |
221 | |
222 | |
223 | GIT_REPOSITORY_FORK_ENABLED = 'gitrepository.fork.enabled' |
224 | @@ -270,6 +272,13 @@ class GitRepositoryNavigation(WebhookTargetNavigationMixin, Navigation): |
225 | """Traverses to the `ICodeImport` for the repository.""" |
226 | return self.context.code_import |
227 | |
228 | + @stepthrough("+build") |
229 | + def traverse_build(self, name): |
230 | + build = get_build_by_id_str(ICIBuildSet, name) |
231 | + if build is None or build.git_repository != self.context: |
232 | + return None |
233 | + return build |
234 | + |
235 | |
236 | class GitRepositoryEditMenu(NavigationMenu): |
237 | """Edit menu for `IGitRepository`.""" |
238 | diff --git a/lib/lp/code/browser/tests/test_cibuild.py b/lib/lp/code/browser/tests/test_cibuild.py |
239 | new file mode 100644 |
240 | index 0000000..662b830 |
241 | --- /dev/null |
242 | +++ b/lib/lp/code/browser/tests/test_cibuild.py |
243 | @@ -0,0 +1,202 @@ |
244 | +# Copyright 2022 Canonical Ltd. This software is licensed under the |
245 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
246 | + |
247 | +"""Test CI build views.""" |
248 | + |
249 | +import re |
250 | + |
251 | +from fixtures import FakeLogger |
252 | +import soupmatchers |
253 | +from storm.locals import Store |
254 | +import transaction |
255 | +from zope.component import getUtility |
256 | +from zope.security.interfaces import Unauthorized |
257 | +from zope.testbrowser.browser import LinkNotFoundError |
258 | + |
259 | +from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
260 | +from lp.buildmaster.enums import BuildStatus |
261 | +from lp.services.webapp import canonical_url |
262 | +from lp.testing import ( |
263 | + ANONYMOUS, |
264 | + BrowserTestCase, |
265 | + login, |
266 | + person_logged_in, |
267 | + TestCaseWithFactory, |
268 | + ) |
269 | +from lp.testing.layers import DatabaseFunctionalLayer |
270 | +from lp.testing.pages import ( |
271 | + extract_text, |
272 | + find_main_content, |
273 | + find_tags_by_class, |
274 | + ) |
275 | + |
276 | + |
277 | +class TestCanonicalUrlForCIBuild(TestCaseWithFactory): |
278 | + |
279 | + layer = DatabaseFunctionalLayer |
280 | + |
281 | + def test_canonical_url(self): |
282 | + repository = self.factory.makeGitRepository() |
283 | + build = self.factory.makeCIBuild(git_repository=repository) |
284 | + self.assertEqual( |
285 | + "http://launchpad.test/%s/+build/%s" % ( |
286 | + repository.shortened_path, build.id), |
287 | + canonical_url(build)) |
288 | + |
289 | + |
290 | +class TestCIBuildOperations(BrowserTestCase): |
291 | + |
292 | + layer = DatabaseFunctionalLayer |
293 | + |
294 | + def setUp(self): |
295 | + super().setUp() |
296 | + self.useFixture(FakeLogger()) |
297 | + self.build = self.factory.makeCIBuild() |
298 | + self.build_url = canonical_url(self.build) |
299 | + self.repository = self.build.git_repository |
300 | + self.buildd_admin = self.factory.makePerson( |
301 | + member_of=[getUtility(ILaunchpadCelebrities).buildd_admin]) |
302 | + |
303 | + def test_retry_build(self): |
304 | + # The owner of a build's repository can retry it. |
305 | + self.build.updateStatus(BuildStatus.FAILEDTOBUILD) |
306 | + transaction.commit() |
307 | + browser = self.getViewBrowser(self.build, user=self.repository.owner) |
308 | + browser.getLink("Retry this build").click() |
309 | + self.assertEqual(self.build_url, browser.getLink("Cancel").url) |
310 | + browser.getControl("Retry build").click() |
311 | + self.assertEqual(self.build_url, browser.url) |
312 | + login(ANONYMOUS) |
313 | + self.assertEqual(BuildStatus.NEEDSBUILD, self.build.status) |
314 | + |
315 | + def test_retry_build_random_user(self): |
316 | + # An unrelated non-admin user cannot retry a build. |
317 | + self.build.updateStatus(BuildStatus.FAILEDTOBUILD) |
318 | + transaction.commit() |
319 | + user = self.factory.makePerson() |
320 | + browser = self.getViewBrowser(self.build, user=user) |
321 | + self.assertRaises( |
322 | + LinkNotFoundError, browser.getLink, "Retry this build") |
323 | + self.assertRaises( |
324 | + Unauthorized, self.getUserBrowser, self.build_url + "/+retry", |
325 | + user=user) |
326 | + |
327 | + def test_retry_build_wrong_state(self): |
328 | + # If the build isn't in an unsuccessful terminal state, you can't |
329 | + # retry it. |
330 | + self.build.updateStatus(BuildStatus.FULLYBUILT) |
331 | + browser = self.getViewBrowser(self.build, user=self.repository.owner) |
332 | + self.assertRaises( |
333 | + LinkNotFoundError, browser.getLink, "Retry this build") |
334 | + |
335 | + def test_cancel_build(self): |
336 | + # The owner of a build's repository can cancel it. |
337 | + self.build.queueBuild() |
338 | + transaction.commit() |
339 | + browser = self.getViewBrowser(self.build, user=self.repository.owner) |
340 | + browser.getLink("Cancel build").click() |
341 | + self.assertEqual(self.build_url, browser.getLink("Cancel").url) |
342 | + browser.getControl("Cancel build").click() |
343 | + self.assertEqual(self.build_url, browser.url) |
344 | + login(ANONYMOUS) |
345 | + self.assertEqual(BuildStatus.CANCELLED, self.build.status) |
346 | + |
347 | + def test_cancel_build_random_user(self): |
348 | + # An unrelated non-admin user cannot cancel a build. |
349 | + self.build.queueBuild() |
350 | + transaction.commit() |
351 | + user = self.factory.makePerson() |
352 | + browser = self.getViewBrowser(self.build, user=user) |
353 | + self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build") |
354 | + self.assertRaises( |
355 | + Unauthorized, self.getUserBrowser, self.build_url + "/+cancel", |
356 | + user=user) |
357 | + |
358 | + def test_cancel_build_wrong_state(self): |
359 | + # If the build isn't queued, you can't cancel it. |
360 | + browser = self.getViewBrowser(self.build, user=self.repository.owner) |
361 | + self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build") |
362 | + |
363 | + def test_rescore_build(self): |
364 | + # A buildd admin can rescore a build. |
365 | + self.build.queueBuild() |
366 | + transaction.commit() |
367 | + browser = self.getViewBrowser(self.build, user=self.buildd_admin) |
368 | + browser.getLink("Rescore build").click() |
369 | + self.assertEqual(self.build_url, browser.getLink("Cancel").url) |
370 | + browser.getControl("Priority").value = "1024" |
371 | + browser.getControl("Rescore build").click() |
372 | + self.assertEqual(self.build_url, browser.url) |
373 | + login(ANONYMOUS) |
374 | + self.assertEqual(1024, self.build.buildqueue_record.lastscore) |
375 | + |
376 | + def test_rescore_build_invalid_score(self): |
377 | + # Build scores can only take numbers. |
378 | + self.build.queueBuild() |
379 | + transaction.commit() |
380 | + browser = self.getViewBrowser(self.build, user=self.buildd_admin) |
381 | + browser.getLink("Rescore build").click() |
382 | + self.assertEqual(self.build_url, browser.getLink("Cancel").url) |
383 | + browser.getControl("Priority").value = "tentwentyfour" |
384 | + browser.getControl("Rescore build").click() |
385 | + self.assertEqual( |
386 | + "Invalid integer data", |
387 | + extract_text(find_tags_by_class(browser.contents, "message")[1])) |
388 | + |
389 | + def test_rescore_build_not_admin(self): |
390 | + # A non-admin user cannot cancel a build. |
391 | + self.build.queueBuild() |
392 | + transaction.commit() |
393 | + user = self.factory.makePerson() |
394 | + browser = self.getViewBrowser(self.build, user=user) |
395 | + self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build") |
396 | + self.assertRaises( |
397 | + Unauthorized, self.getUserBrowser, self.build_url + "/+rescore", |
398 | + user=user) |
399 | + |
400 | + def test_rescore_build_wrong_state(self): |
401 | + # If the build isn't NEEDSBUILD, you can't rescore it. |
402 | + self.build.queueBuild() |
403 | + with person_logged_in(self.repository.owner): |
404 | + self.build.cancel() |
405 | + browser = self.getViewBrowser(self.build, user=self.buildd_admin) |
406 | + self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build") |
407 | + |
408 | + def test_rescore_build_wrong_state_stale_link(self): |
409 | + # An attempt to rescore a non-queued build from a stale link shows a |
410 | + # sensible error message. |
411 | + self.build.queueBuild() |
412 | + with person_logged_in(self.repository.owner): |
413 | + self.build.cancel() |
414 | + browser = self.getViewBrowser( |
415 | + self.build, "+rescore", user=self.buildd_admin) |
416 | + self.assertEqual(self.build_url, browser.url) |
417 | + self.assertThat(browser.contents, soupmatchers.HTMLContains( |
418 | + soupmatchers.Tag( |
419 | + "notification", "div", attrs={"class": "warning message"}, |
420 | + text="Cannot rescore this build because it is not queued."))) |
421 | + |
422 | + def test_builder_history(self): |
423 | + Store.of(self.build).flush() |
424 | + self.build.updateStatus( |
425 | + BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder()) |
426 | + title = self.build.title |
427 | + browser = self.getViewBrowser(self.build.builder, "+history") |
428 | + self.assertTextMatchesExpressionIgnoreWhitespace( |
429 | + r"Build history.*%s" % re.escape(title), |
430 | + extract_text(find_main_content(browser.contents))) |
431 | + self.assertEqual(self.build_url, browser.getLink(title).url) |
432 | + |
433 | + def makeBuildingRecipe(self): |
434 | + builder = self.factory.makeBuilder() |
435 | + build = self.factory.makeCIBuild() |
436 | + build.updateStatus(BuildStatus.BUILDING, builder=builder) |
437 | + build.queueBuild() |
438 | + build.buildqueue_record.builder = builder |
439 | + build.buildqueue_record.logtail = "tail of the log" |
440 | + return build |
441 | + |
442 | + def test_builder_index_public(self): |
443 | + build = self.makeBuildingRecipe() |
444 | + browser = self.getViewBrowser(build.builder, no_login=True) |
445 | + self.assertIn("tail of the log", browser.contents) |
446 | diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py |
447 | index e1abcf8..470cd95 100644 |
448 | --- a/lib/lp/code/interfaces/cibuild.py |
449 | +++ b/lib/lp/code/interfaces/cibuild.py |
450 | @@ -130,6 +130,19 @@ class ICIBuildView(IPackageBuildView): |
451 | cannot be parsed. |
452 | """ |
453 | |
454 | + def getFileByName(filename): |
455 | + """Return the corresponding `ILibraryFileAlias` in this context. |
456 | + |
457 | + The following file types (and extension) can be looked up: |
458 | + |
459 | + * Build log: '.txt.gz' |
460 | + * Upload log: '_log.txt' |
461 | + |
462 | + :param filename: The filename to look up. |
463 | + :raises NotFoundError: if no file exists with the given name. |
464 | + :return: The corresponding `ILibraryFileAlias`. |
465 | + """ |
466 | + |
467 | |
468 | class ICIBuildEdit(IBuildFarmJobEdit): |
469 | """`ICIBuild` methods that require launchpad.Edit.""" |
470 | diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py |
471 | index 4da34fb..785b1cc 100644 |
472 | --- a/lib/lp/code/model/cibuild.py |
473 | +++ b/lib/lp/code/model/cibuild.py |
474 | @@ -359,6 +359,20 @@ class CIBuild(PackageBuildMixin, StormBase): |
475 | "%s: %s" % (msg % self.git_repository.unique_name, e)) |
476 | return parse_configuration(self.git_repository, blob) |
477 | |
478 | + def getFileByName(self, filename): |
479 | + """See `ICIBuild`.""" |
480 | + if filename.endswith(".txt.gz"): |
481 | + file_object = self.log |
482 | + elif filename.endswith("_log.txt"): |
483 | + file_object = self.upload_log |
484 | + else: |
485 | + file_object = None |
486 | + |
487 | + if file_object is not None and file_object.filename == filename: |
488 | + return file_object |
489 | + |
490 | + raise NotFoundError(filename) |
491 | + |
492 | def verifySuccessfulUpload(self): |
493 | """See `IPackageBuild`.""" |
494 | # We have no interesting checks to perform here. |
495 | diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py |
496 | index 6118379..91b24a2 100644 |
497 | --- a/lib/lp/code/model/tests/test_cibuild.py |
498 | +++ b/lib/lp/code/model/tests/test_cibuild.py |
499 | @@ -23,6 +23,7 @@ from zope.component import getUtility |
500 | from zope.security.proxy import removeSecurityProxy |
501 | |
502 | from lp.app.enums import InformationType |
503 | +from lp.app.errors import NotFoundError |
504 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
505 | from lp.buildmaster.enums import ( |
506 | BuildQueueStatus, |
507 | @@ -244,6 +245,17 @@ class TestCIBuild(TestCaseWithFactory): |
508 | build = self.factory.makeCIBuild() |
509 | self.assertEqual('CIBUILD-%d' % build.id, build.build_cookie) |
510 | |
511 | + def test_getFileByName_logs(self): |
512 | + # getFileByName returns the logs when requested by name. |
513 | + build = self.factory.makeCIBuild() |
514 | + build.setLog( |
515 | + self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz")) |
516 | + self.assertEqual(build.log, build.getFileByName("buildlog.txt.gz")) |
517 | + self.assertRaises(NotFoundError, build.getFileByName, "foo") |
518 | + build.storeUploadLog("uploaded") |
519 | + self.assertEqual( |
520 | + build.upload_log, build.getFileByName(build.upload_log.filename)) |
521 | + |
522 | def addFakeBuildLog(self, build): |
523 | build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt")) |
524 | |
525 | diff --git a/lib/lp/code/templates/cibuild-index.pt b/lib/lp/code/templates/cibuild-index.pt |
526 | new file mode 100644 |
527 | index 0000000..35ef0cf |
528 | --- /dev/null |
529 | +++ b/lib/lp/code/templates/cibuild-index.pt |
530 | @@ -0,0 +1,181 @@ |
531 | +<html |
532 | + xmlns="http://www.w3.org/1999/xhtml" |
533 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
534 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
535 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
536 | + metal:use-macro="view/macro:page/main_only" |
537 | + i18n:domain="launchpad" |
538 | +> |
539 | + |
540 | + <body> |
541 | + |
542 | + <tal:registering metal:fill-slot="registering"> |
543 | + created |
544 | + <span tal:content="context/date_created/fmt:displaydate" |
545 | + tal:attributes="title context/date_created/fmt:datetime"/> |
546 | + </tal:registering> |
547 | + |
548 | + <div metal:fill-slot="main"> |
549 | + |
550 | + <div class="yui-g"> |
551 | + |
552 | + <div id="status" class="yui-u first"> |
553 | + <div class="portlet"> |
554 | + <div metal:use-macro="template/macros/status"/> |
555 | + </div> |
556 | + </div> |
557 | + |
558 | + <div id="details" class="yui-u"> |
559 | + <div class="portlet"> |
560 | + <div metal:use-macro="template/macros/details"/> |
561 | + </div> |
562 | + </div> |
563 | + |
564 | + </div> <!-- yui-g --> |
565 | + |
566 | + <div id="buildlog" class="portlet" |
567 | + tal:condition="context/status/enumvalue:BUILDING"> |
568 | + <div metal:use-macro="template/macros/buildlog"/> |
569 | + </div> |
570 | + |
571 | + </div> <!-- main --> |
572 | + |
573 | + |
574 | +<metal:macros fill-slot="bogus"> |
575 | + |
576 | + <metal:macro define-macro="details"> |
577 | + <tal:comment replace="nothing"> |
578 | + Details section. |
579 | + </tal:comment> |
580 | + <h2>Build details</h2> |
581 | + <div class="two-column-list"> |
582 | + <dl> |
583 | + <dt>Source:</dt> |
584 | + <dd> |
585 | + <tal:repository |
586 | + replace="structure context/git_repository/fmt:link"/> |
587 | + </dd> |
588 | + </dl> |
589 | + <dl> |
590 | + <dt>Commit:</dt> |
591 | + <dd> |
592 | + <tal:commit replace="context/commit_sha1"/> |
593 | + </dd> |
594 | + </dl> |
595 | + <dl> |
596 | + <dt>Series:</dt> |
597 | + <dd><a class="sprite distribution" |
598 | + tal:define="series context/distro_series" |
599 | + tal:attributes="href series/fmt:url" |
600 | + tal:content="series/displayname"/> |
601 | + </dd> |
602 | + </dl> |
603 | + <dl> |
604 | + <dt>Architecture:</dt> |
605 | + <dd><a class="sprite distribution" |
606 | + tal:define="archseries context/distro_arch_series" |
607 | + tal:attributes="href archseries/fmt:url" |
608 | + tal:content="archseries/architecturetag"/> |
609 | + </dd> |
610 | + </dl> |
611 | + </div> |
612 | + </metal:macro> |
613 | + |
614 | + <metal:macro define-macro="status"> |
615 | + <tal:comment replace="nothing"> |
616 | + Status section. |
617 | + </tal:comment> |
618 | + <h2>Build status</h2> |
619 | + <p> |
620 | + <span tal:replace="structure context/image:icon" /> |
621 | + <span tal:attributes=" |
622 | + class string:buildstatus${context/status/name};" |
623 | + tal:content="context/status/title"/> |
624 | + <tal:builder condition="context/builder"> |
625 | + on <a tal:content="context/builder/title" |
626 | + tal:attributes="href context/builder/fmt:url"/> |
627 | + </tal:builder> |
628 | + <tal:retry define="link context/menu:context/retry" |
629 | + condition="link/enabled" |
630 | + replace="structure link/fmt:link" /> |
631 | + <tal:cancel define="link context/menu:context/cancel" |
632 | + condition="link/enabled" |
633 | + replace="structure link/fmt:link" /> |
634 | + </p> |
635 | + |
636 | + <ul> |
637 | + <li tal:condition="context/dependencies"> |
638 | + Missing build dependencies: <em tal:content="context/dependencies"/> |
639 | + </li> |
640 | + <tal:reallypending condition="context/buildqueue_record"> |
641 | + <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING"> |
642 | + <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime"> |
643 | + Start <tal:eta replace="eta/fmt:approximatedate"/> |
644 | + (<span tal:replace="context/buildqueue_record/lastscore"/>) |
645 | + <a href="https://help.launchpad.net/Packaging/BuildScores" |
646 | + target="_blank">What's this?</a> |
647 | + </li> |
648 | + </tal:pending> |
649 | + </tal:reallypending> |
650 | + <tal:started condition="context/date_started"> |
651 | + <li tal:condition="context/date_started"> |
652 | + Started <span |
653 | + tal:define="start context/date_started" |
654 | + tal:attributes="title start/fmt:datetime" |
655 | + tal:content="start/fmt:displaydate"/> |
656 | + </li> |
657 | + </tal:started> |
658 | + <tal:finish condition="not: context/date_finished"> |
659 | + <li tal:define="eta context/eta" tal:condition="context/eta"> |
660 | + Estimated finish <tal:eta replace="eta/fmt:approximatedate"/> |
661 | + </li> |
662 | + </tal:finish> |
663 | + |
664 | + <li tal:condition="context/date_finished"> |
665 | + Finished <span |
666 | + tal:attributes="title context/date_finished/fmt:datetime" |
667 | + tal:content="context/date_finished/fmt:displaydate"/> |
668 | + <tal:duration condition="context/duration"> |
669 | + (took <span tal:replace="context/duration/fmt:exactduration"/>) |
670 | + </tal:duration> |
671 | + </li> |
672 | + <li tal:define="file context/log" |
673 | + tal:condition="file"> |
674 | + <a class="sprite download" |
675 | + tal:attributes="href context/log_url">buildlog</a> |
676 | + (<span tal:replace="file/content/filesize/fmt:bytes" />) |
677 | + </li> |
678 | + <li tal:define="file context/upload_log" |
679 | + tal:condition="file"> |
680 | + <a class="sprite download" |
681 | + tal:attributes="href context/upload_log_url">uploadlog</a> |
682 | + (<span tal:replace="file/content/filesize/fmt:bytes" />) |
683 | + </li> |
684 | + </ul> |
685 | + |
686 | + <div |
687 | + style="margin-top: 1.5em" |
688 | + tal:define="link context/menu:context/rescore" |
689 | + tal:condition="link/enabled" |
690 | + > |
691 | + <a tal:replace="structure link/fmt:link"/> |
692 | + </div> |
693 | + </metal:macro> |
694 | + |
695 | + <metal:macro define-macro="buildlog"> |
696 | + <tal:comment replace="nothing"> |
697 | + Buildlog section. |
698 | + </tal:comment> |
699 | + <h2>Buildlog</h2> |
700 | + <div id="buildlog-tail" class="logtail" |
701 | + tal:define="logtail context/buildqueue_record/logtail" |
702 | + tal:content="structure logtail/fmt:text-to-html"/> |
703 | + <p class="lesser" tal:condition="view/user"> |
704 | + Updated on <span tal:replace="structure view/user/fmt:local-time"/> |
705 | + </p> |
706 | + </metal:macro> |
707 | + |
708 | +</metal:macros> |
709 | + |
710 | + </body> |
711 | +</html> |
712 | diff --git a/lib/lp/code/templates/cibuild-retry.pt b/lib/lp/code/templates/cibuild-retry.pt |
713 | new file mode 100644 |
714 | index 0000000..ba08004 |
715 | --- /dev/null |
716 | +++ b/lib/lp/code/templates/cibuild-retry.pt |
717 | @@ -0,0 +1,28 @@ |
718 | +<html |
719 | + xmlns="http://www.w3.org/1999/xhtml" |
720 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
721 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
722 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
723 | + metal:use-macro="view/macro:page/main_only" |
724 | + i18n:domain="launchpad"> |
725 | +<body> |
726 | + |
727 | + <div metal:fill-slot="main"> |
728 | + <div metal:use-macro="context/@@launchpad_form/form"> |
729 | + <div metal:fill-slot="extra_info"> |
730 | + <p> |
731 | + The status of <dfn tal:content="context/title" /> is |
732 | + <span tal:replace="context/status/title" />. |
733 | + </p> |
734 | + <p>Retrying this build will destroy its history and logs.</p> |
735 | + <p> |
736 | + By default, this build will be retried only after other pending |
737 | + builds; please contact a build daemon administrator if you need |
738 | + special treatment. |
739 | + </p> |
740 | + </div> |
741 | + </div> |
742 | + </div> |
743 | + |
744 | +</body> |
745 | +</html> |