Merge launchpad:stable into launchpad:db-devel

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: f92b6e0bb2624f8a9125e3f45b7621c6e230c5ff
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: launchpad:stable
Merge into: launchpad:db-devel
Diff against target: 1977 lines (+959/-49)
34 files modified
Makefile (+61/-9)
database/sampledata/current-dev.sql (+2/-2)
database/sampledata/current.sql (+2/-2)
lib/lp/_schema_circular_imports.py (+10/-1)
lib/lp/archivepublisher/publishing.py (+4/-2)
lib/lp/archivepublisher/tests/test_publisher.py (+40/-0)
lib/lp/code/browser/configure.zcml (+6/-0)
lib/lp/code/browser/gitrepository.py (+13/-0)
lib/lp/code/configure.zcml (+34/-0)
lib/lp/code/enums.py (+50/-0)
lib/lp/code/interfaces/gitrepository.py (+195/-0)
lib/lp/code/interfaces/webservice.py (+2/-0)
lib/lp/code/model/gitrepository.py (+158/-1)
lib/lp/code/model/tests/test_gitrepository.py (+197/-0)
lib/lp/registry/configure.zcml (+2/-1)
lib/lp/registry/doc/distribution-mirror.txt (+12/-5)
lib/lp/registry/interfaces/distroseries.py (+10/-1)
lib/lp/registry/model/distributionmirror.py (+5/-4)
lib/lp/registry/model/distroseries.py (+10/-0)
lib/lp/registry/model/person.py (+1/-1)
lib/lp/registry/tests/test_distributionmirror.py (+15/-7)
lib/lp/registry/tests/test_distroseries.py (+11/-1)
lib/lp/security.py (+19/-0)
lib/lp/services/config/schema-lazr.conf (+4/-0)
lib/lp/services/librarianserver/swift.py (+1/-0)
lib/lp/services/librarianserver/tests/test_swift.py (+9/-0)
lib/lp/services/webapp/tests/test_servers.py (+8/-0)
lib/lp/services/webservice/configuration.py (+19/-4)
lib/lp/soyuz/scripts/initialize_distroseries.py (+4/-1)
lib/lp/soyuz/scripts/tests/test_initialize_distroseries.py (+3/-1)
lib/lp/testing/factory.py (+29/-1)
lib/lp/testing/swift/fakeswift.py (+2/-0)
lib/lp/translations/scripts/po_import.py (+2/-5)
lib/lp/translations/scripts/tests/test_translations_import.py (+19/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+412895@code.launchpad.net

Commit message

Manually merge from stable to fix time-dependent tests

Description of the change

This is needed on db-devel to make tests pass again; see https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/412682.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/Makefile b/Makefile
2index b2daade..90a640e 100644
3--- a/Makefile
4+++ b/Makefile
5@@ -96,15 +96,19 @@ PIP_BIN = \
6 bin/with-xvfb
7
8 # DO NOT ALTER : this should just build by default
9+.PHONY: default
10 default: inplace
11
12+.PHONY: schema
13 schema: build
14 $(MAKE) -C database/schema
15 $(RM) -r /var/tmp/fatsam
16
17+.PHONY: newsampledata
18 newsampledata:
19 $(MAKE) -C database/schema newsampledata
20
21+.PHONY: hosted_branches
22 hosted_branches: $(PY)
23 $(PY) ./utilities/make-dummy-hosted-branches
24
25@@ -115,6 +119,7 @@ $(API_INDEX): $(VERSION_INFO) $(PY)
26 --force "$(APIDOC_TMPDIR)"
27 mv $(APIDOC_TMPDIR) $(APIDOC_DIR)
28
29+.PHONY: apidoc
30 ifdef LP_MAKE_NO_WADL
31 apidoc:
32 @echo "Skipping WADL generation."
33@@ -123,15 +128,18 @@ apidoc: compile $(API_INDEX)
34 endif
35
36 # Used to generate HTML developer documentation for Launchpad.
37+.PHONY: doc
38 doc:
39 $(MAKE) -C doc/ html
40
41 # Run by PQM.
42+.PHONY: check_config
43 check_config: build
44 bin/test -m lp.services.config.tests -vvt test_config
45
46 # Clean before running the test suite, since the build might fail depending
47 # what source changes happened. (e.g. apidoc depends on interfaces)
48+.PHONY: check
49 check: clean build
50 # Run all tests. test_on_merge.py takes care of setting up the
51 # database.
52@@ -141,6 +149,7 @@ check: clean build
53 logs:
54 mkdir logs
55
56+.PHONY: codehosting-dir
57 codehosting-dir:
58 mkdir -p $(CODEHOSTING_ROOT)
59 mkdir -p $(CODEHOSTING_ROOT)/mirrors
60@@ -155,11 +164,13 @@ ifneq ($(SUDO_UID),)
61 fi
62 endif
63
64+.PHONY: inplace
65 inplace: build logs clean_logs codehosting-dir
66 if [ -d /srv/launchpad.test ]; then \
67 ln -sfn $(WD)/build/js $(CONVOY_ROOT); \
68 fi
69
70+.PHONY: build
71 build: compile apidoc jsbuild css_combine
72
73 # LP_SOURCEDEPS_PATH should point to the sourcecode directory, but we
74@@ -174,6 +185,7 @@ else
75 @exit 1
76 endif
77
78+.PHONY: css_combine
79 css_combine: jsbuild_widget_css
80 ${SHHH} bin/sprite-util create-image
81 ${SHHH} bin/sprite-util create-css
82@@ -184,18 +196,20 @@ css_combine: jsbuild_widget_css
83 # XXX 2020-06-12 twom This should have `--output-style compressed`. Removed for debugging purposes
84 SASS_BINARY_PATH=$(NODE_SASS_BINARY) $(YARN) run node-sass --include-path $(WD)/$(ICING) --follow --output $(WD)/$(ICING) $(WD)/$(ICING)/combo.scss
85
86+.PHONY: css_watch
87 css_watch: jsbuild_widget_css
88 ${SHHH} bin/sprite-util create-image
89 ${SHHH} bin/sprite-util create-css
90 ln -sfn ../../../../yarn/node_modules/yui $(ICING)/yui
91 SASS_BINARY_PATH=$(NODE_SASS_BINARY) $(YARN) run node-sass --include-path $(WD)/$(ICING) --follow --output $(WD)/$(ICING) $(WD)/$(ICING)/ --watch --recursive
92
93-
94+.PHONY: jsbuild_widget_css
95 jsbuild_widget_css: bin/jsbuild
96 ${SHHH} bin/jsbuild \
97 --srcdir lib/lp/app/javascript \
98 --builddir $(LP_BUILT_JS_ROOT)
99
100+.PHONY: jsbuild_watch
101 jsbuild_watch:
102 $(PY) bin/watch_jsbuild
103
104@@ -218,6 +232,7 @@ $(JS_BUILD_DIR)/.production: yarn/package.json | $(YARN_BUILD)
105 $(YUI_SYMLINK): $(JS_BUILD_DIR)/.production
106 ln -sfn ../../yarn/node_modules/yui $@
107
108+.PHONY: $(LP_JS_BUILD)
109 $(LP_JS_BUILD): | $(JS_BUILD_DIR)
110 mkdir -p $@/services
111 for jsdir in lib/lp/*/javascript lib/lp/services/*/javascript; do \
112@@ -227,6 +242,7 @@ $(LP_JS_BUILD): | $(JS_BUILD_DIR)
113 find $@ -name 'tests' -type d | xargs rm -rf
114 LC_ALL=C.UTF-8 bin/lpjsmin -p $@
115
116+.PHONY: jsbuild
117 jsbuild: $(LP_JS_BUILD) $(YUI_SYMLINK)
118 LC_ALL=C.UTF-8 utilities/js-deps -n LP_MODULES -s build/js/lp \
119 -x '-min.js' -o build/js/lp/meta.js >/dev/null
120@@ -252,6 +268,7 @@ requirements/combined.txt: \
121 # It doesn't seem to be straightforward to build a wheelhouse of all our
122 # dependencies without also building a useless wheel of Launchpad itself;
123 # fortunately that doesn't take too long, and we just remove it afterwards.
124+.PHONY: build_wheels
125 build_wheels: $(PIP_BIN) requirements/combined.txt
126 $(RM) -r wheelhouse wheels
127 $(SHHH) $(PIP) wheel \
128@@ -261,6 +278,7 @@ build_wheels: $(PIP_BIN) requirements/combined.txt
129 $(MAKE) clean_pip
130
131 # Compatibility
132+.PHONY: build_eggs
133 build_eggs: build_wheels
134
135 # setuptools won't touch files that would have the same contents, but for
136@@ -290,6 +308,7 @@ $(subst $(VENV_PYTHON),,$(PIP_BIN)): $(VENV_PYTHON)
137
138 # Explicitly update version-info.py rather than declaring $(VERSION_INFO) as
139 # a prerequisite, to make sure it's up to date when doing deployments.
140+.PHONY: compile
141 compile: $(VENV_PYTHON)
142 ${SHHH} utilities/relocate-virtualenv env
143 $(PYTHON) utilities/link-system-packages.py \
144@@ -297,22 +316,28 @@ compile: $(VENV_PYTHON)
145 ${SHHH} bin/build-twisted-plugin-cache
146 scripts/update-version-info.sh
147
148+.PHONY: test_build
149 test_build: build
150 bin/test $(TESTFLAGS) $(TESTOPTS)
151
152+.PHONY: test_inplace
153 test_inplace: inplace
154 bin/test $(TESTFLAGS) $(TESTOPTS)
155
156+.PHONY: ftest_build
157 ftest_build: build
158 bin/test -f $(TESTFLAGS) $(TESTOPTS)
159
160+.PHONY: ftest_inplace
161 ftest_inplace: inplace
162 bin/test -f $(TESTFLAGS) $(TESTOPTS)
163
164+.PHONY: run
165 run: build inplace stop
166 bin/run -r librarian,bing-webservice,memcached,rabbitmq \
167 -i $(LPCONFIG)
168
169+.PHONY: run-testapp
170 run-testapp: LPCONFIG=testrunner-appserver
171 run-testapp: build inplace stop
172 LPCONFIG=$(LPCONFIG) INTERACTIVE_TESTS=1 bin/run-testapp \
173@@ -321,40 +346,50 @@ run-testapp: build inplace stop
174 run.gdb:
175 echo 'run' > run.gdb
176
177+.PHONY: start-gdb
178 start-gdb: build inplace stop support_files run.gdb
179 nohup gdb -x run.gdb --args bin/run -i $(LPCONFIG) \
180 -r librarian,bing-webservice
181 > ${LPCONFIG}-nohup.out 2>&1 &
182
183+.PHONY: run_all
184 run_all: build inplace stop
185 bin/run \
186 -r librarian,sftp,codebrowse,bing-webservice,\
187 memcached,rabbitmq -i $(LPCONFIG)
188
189+.PHONY: run_codebrowse
190 run_codebrowse: compile
191 BRZ_PLUGIN_PATH=brzplugins $(PY) scripts/start-loggerhead.py
192
193+.PHONY: start_codebrowse
194 start_codebrowse: compile
195 BRZ_PLUGIN_PATH=$(shell pwd)/brzplugins $(PY) scripts/start-loggerhead.py --daemon
196
197+.PHONY: stop_codebrowse
198 stop_codebrowse:
199 $(PY) scripts/stop-loggerhead.py
200
201+.PHONY: run_codehosting
202 run_codehosting: build inplace stop
203 bin/run -r librarian,sftp,codebrowse,rabbitmq -i $(LPCONFIG)
204
205+.PHONY: start_librarian
206 start_librarian: compile
207 bin/start_librarian
208
209+.PHONY: stop_librarian
210 stop_librarian:
211 bin/killservice librarian
212
213 $(VERSION_INFO):
214 scripts/update-version-info.sh
215
216+.PHONY: support_files
217 support_files: $(API_INDEX) $(VERSION_INFO)
218
219 # Intended for use on developer machines
220+.PHONY: start
221 start: inplace stop support_files initscript-start
222
223 # Run as a daemon - hack using nohup until we move back to using zdaemon
224@@ -363,42 +398,52 @@ start: inplace stop support_files initscript-start
225 # will not work as expected. For use on production servers, where
226 # we know we don't need the extra steps in a full "make start"
227 # because of how the code is deployed/built.
228+.PHONY: initscript-start
229 initscript-start:
230 nohup bin/run -i $(LPCONFIG) > ${LPCONFIG}-nohup.out 2>&1 &
231
232 # Intended for use on developer machines
233+.PHONY: stop
234 stop: build initscript-stop
235
236 # Kill launchpad last - other services will probably shutdown with it,
237 # so killing them after is a race condition. For use on production
238 # servers, where we know we don't need the extra steps in a full
239 # "make stop" because of how the code is deployed/built.
240+.PHONY: initscript-stop
241 initscript-stop:
242 bin/killservice librarian launchpad
243
244+.PHONY: shutdown
245 shutdown: scheduleoutage stop
246 $(RM) +maintenancetime.txt
247
248+.PHONY: scheduleoutage
249 scheduleoutage:
250 echo Scheduling outage in ${MINS_TO_SHUTDOWN} mins
251 date --iso-8601=minutes -u -d +${MINS_TO_SHUTDOWN}mins > +maintenancetime.txt
252 echo Sleeping ${MINS_TO_SHUTDOWN} mins
253 sleep ${MINS_TO_SHUTDOWN}m
254
255+.PHONY: harness
256 harness: bin/harness
257 bin/harness
258
259+.PHONY: iharness
260 iharness: bin/iharness
261 bin/iharness
262
263+.PHONY: rebuildfti
264 rebuildfti:
265 @echo Rebuilding FTI indexes on launchpad_dev database
266 $(PY) database/schema/fti.py -d launchpad_dev --force
267
268+.PHONY: clean_js
269 clean_js:
270 $(RM) -r $(JS_BUILD_DIR)
271 $(RM) -r yarn/node_modules
272
273+.PHONY: clean_pip
274 clean_pip:
275 $(RM) -r build
276 if [ -d $(CONVOY_ROOT) ]; then $(RM) -r $(CONVOY_ROOT) ; fi
277@@ -408,11 +453,14 @@ clean_pip:
278 $(RM) .installed.cfg
279
280 # Compatibility.
281+.PHONY: clean_buildout
282 clean_buildout: clean_pip
283
284+.PHONY: clean_logs
285 clean_logs:
286 $(RM) logs/thread*.request
287
288+.PHONY: lxc-clean
289 lxc-clean: clean_js clean_pip clean_logs
290 # XXX: BradCrittenden 2012-05-25 bug=1004514:
291 # It is important for parallel tests inside LXC that the
292@@ -446,31 +494,38 @@ lxc-clean: clean_js clean_pip clean_logs
293 $(RM) -r /var/tmp/launchpad_mailqueue; \
294 fi
295
296+.PHONY: clean
297 clean: lxc-clean
298 $(RM) -r $(CODEHOSTING_ROOT)
299
300+.PHONY: realclean
301 realclean: clean
302 $(RM) TAGS tags
303
304+.PHONY: potemplates
305 potemplates: launchpad.pot
306
307 # Generate launchpad.pot by extracting message ids from the source
308 # XXX cjwatson 2017-09-04: This was previously done using i18nextract from
309 # z3c.recipe.i18n, but has been broken for some time. The place to start in
310 # putting this together again is probably zope.app.locales.
311+.PHONY: launchpad.pot
312 launchpad.pot:
313 echo "POT generation not currently supported; help us fix this!" >&2
314 exit 1
315
316 # Called by the rocketfuel-setup script. You probably don't want to run this
317 # on its own.
318+.PHONY: install
319 install: reload-apache
320
321+.PHONY: copy-certificates
322 copy-certificates:
323 mkdir -p /etc/apache2/ssl
324 cp configs/$(LPCONFIG)/launchpad.crt /etc/apache2/ssl/
325 cp configs/$(LPCONFIG)/launchpad.key /etc/apache2/ssl/
326
327+.PHONY: copy-apache-config
328 copy-apache-config: codehosting-dir
329 # Byte-compile scripts/_pythonpath.py first, otherwise Apache may do
330 # so as root and cause permission problems.
331@@ -494,19 +549,23 @@ copy-apache-config: codehosting-dir
332 chown $(SUDO_UID):$(SUDO_GID) /srv/launchpad.test; \
333 fi
334
335+.PHONY: enable-apache-launchpad
336 enable-apache-launchpad: copy-apache-config copy-certificates
337 [ ! -e /etc/apache2/mods-available/version.load ] || a2enmod version
338 a2ensite local-launchpad
339
340+.PHONY: reload-apache
341 reload-apache: enable-apache-launchpad
342 service apache2 restart
343
344+.PHONY: TAGS
345 TAGS: compile
346 # emacs tags
347 ctags -R -e --languages=-JavaScript --python-kinds=-i -f $@.new \
348 $(CURDIR)/lib "$(SITE_PACKAGES)"
349 mv $@.new $@
350
351+.PHONY: tags
352 tags: compile
353 # vi tags
354 ctags -R --languages=-JavaScript --python-kinds=-i -f $@.new \
355@@ -516,16 +575,9 @@ tags: compile
356 PYDOCTOR = pydoctor
357 PYDOCTOR_OPTIONS =
358
359+.PHONY: pydoctor
360 pydoctor:
361 $(PYDOCTOR) --make-html --html-output=apidocs --add-package=lib/lp \
362 --add-package=lib/canonical --project-name=Launchpad \
363 --docformat restructuredtext --verbose-about epytext-summary \
364 $(PYDOCTOR_OPTIONS)
365-
366-.PHONY: apidoc build_eggs build_wheels check check_config \
367- clean clean_buildout clean_js clean_logs clean_pip compile \
368- css_combine debug default doc ftest_build ftest_inplace \
369- hosted_branches jsbuild jsbuild_widget_css launchpad.pot \
370- pydoctor realclean reload-apache run run-testapp runner schema \
371- sprite_css sprite_image start stop TAGS tags test_build \
372- test_inplace $(LP_JS_BUILD)
373diff --git a/database/sampledata/current-dev.sql b/database/sampledata/current-dev.sql
374index 1a40702..56cdf56 100644
375--- a/database/sampledata/current-dev.sql
376+++ b/database/sampledata/current-dev.sql
377@@ -3266,8 +3266,8 @@ ALTER TABLE codereviewvote ENABLE TRIGGER ALL;
378
379 ALTER TABLE commercialsubscription DISABLE TRIGGER ALL;
380
381-INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (1, '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.838229', '2022-01-01 00:00:00', 10, 16, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:37.838229+00:00');
382-INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (2, '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.912691', '2022-01-01 00:00:00', 10, 17, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:46.912691+00:00');
383+INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (1, '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.838229', '2032-01-01 00:00:00', 10, 16, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:37.838229+00:00');
384+INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (2, '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.912691', '2032-01-01 00:00:00', 10, 17, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:46.912691+00:00');
385
386
387 ALTER TABLE commercialsubscription ENABLE TRIGGER ALL;
388diff --git a/database/sampledata/current.sql b/database/sampledata/current.sql
389index 9e0310f..df050c1 100644
390--- a/database/sampledata/current.sql
391+++ b/database/sampledata/current.sql
392@@ -3198,8 +3198,8 @@ ALTER TABLE codereviewvote ENABLE TRIGGER ALL;
393
394 ALTER TABLE commercialsubscription DISABLE TRIGGER ALL;
395
396-INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (1, '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.838229', '2022-01-01 00:00:00', 10, 16, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:37.838229+00:00');
397-INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (2, '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.912691', '2022-01-01 00:00:00', 10, 17, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:46.912691+00:00');
398+INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (1, '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.75983', '2012-10-08 11:09:37.838229', '2032-01-01 00:00:00', 10, 16, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:37.838229+00:00');
399+INSERT INTO commercialsubscription (id, date_created, date_last_modified, date_starts, date_expires, status, product, registrant, purchaser, whiteboard, sales_system_id) VALUES (2, '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.861812', '2012-10-08 11:09:46.912691', '2032-01-01 00:00:00', 10, 17, 65, 65, 'Complimentary 30 day subscription. -- Launchpad 2012-10-08', 'complimentary-30-day-2012-10-08 11:09:46.912691+00:00');
400
401
402 ALTER TABLE commercialsubscription ENABLE TRIGGER ALL;
403diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
404index af21a01..09fe6e4 100644
405--- a/lib/lp/_schema_circular_imports.py
406+++ b/lib/lp/_schema_circular_imports.py
407@@ -64,7 +64,10 @@ from lp.code.interfaces.codereviewcomment import ICodeReviewComment
408 from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
409 from lp.code.interfaces.diff import IPreviewDiff
410 from lp.code.interfaces.gitref import IGitRef
411-from lp.code.interfaces.gitrepository import IGitRepository
412+from lp.code.interfaces.gitrepository import (
413+ IGitRepository,
414+ IRevisionStatusReport,
415+ )
416 from lp.code.interfaces.gitrule import (
417 IGitNascentRule,
418 IGitNascentRuleGrant,
419@@ -517,6 +520,8 @@ patch_entry_return_type(IGitRepository, 'subscribe', IGitSubscription)
420 patch_entry_return_type(IGitRepository, 'getSubscription', IGitSubscription)
421 patch_reference_property(IGitRepository, 'code_import', ICodeImport)
422 patch_entry_return_type(IGitRepository, 'getRefByPath', IGitRef)
423+patch_collection_return_type(
424+ IGitRepository, 'getStatusReports', IRevisionStatusReport)
425 patch_collection_property(
426 IGitRepository, '_api_landing_targets', IBranchMergeProposal)
427 patch_collection_property(
428@@ -528,6 +533,10 @@ patch_collection_return_type(
429 patch_list_parameter_type(
430 IGitRepository, 'setRules', 'rules', InlineObject(schema=IGitNascentRule))
431
432+# IRevisionStatusReport
433+patch_reference_property(
434+ IRevisionStatusReport, 'git_repository', IGitRepository)
435+
436 # ILiveFSFile
437 patch_reference_property(ILiveFSFile, 'livefsbuild', ILiveFSBuild)
438
439diff --git a/lib/lp/archivepublisher/publishing.py b/lib/lp/archivepublisher/publishing.py
440index f18ca9f..a5a0cc4 100644
441--- a/lib/lp/archivepublisher/publishing.py
442+++ b/lib/lp/archivepublisher/publishing.py
443@@ -1235,8 +1235,10 @@ class Publisher:
444 release_file["Components"] = " ".join(
445 reorder_components(all_components))
446 release_file["Description"] = drsummary
447- if (pocket == PackagePublishingPocket.BACKPORTS and
448- distroseries.backports_not_automatic):
449+ if ((pocket == PackagePublishingPocket.BACKPORTS and
450+ distroseries.backports_not_automatic) or
451+ (pocket == PackagePublishingPocket.PROPOSED and
452+ distroseries.proposed_not_automatic)):
453 release_file["NotAutomatic"] = "yes"
454 release_file["ButAutomaticUpgrades"] = "yes"
455
456diff --git a/lib/lp/archivepublisher/tests/test_publisher.py b/lib/lp/archivepublisher/tests/test_publisher.py
457index 6ff8cb2..c0891dd 100644
458--- a/lib/lp/archivepublisher/tests/test_publisher.py
459+++ b/lib/lp/archivepublisher/tests/test_publisher.py
460@@ -116,6 +116,7 @@ from lp.testing.matchers import FileContainsBytes
461
462
463 RELEASE = PackagePublishingPocket.RELEASE
464+PROPOSED = PackagePublishingPocket.PROPOSED
465 BACKPORTS = PackagePublishingPocket.BACKPORTS
466
467
468@@ -2064,6 +2065,45 @@ class TestPublisher(TestPublisherBase):
469 self.assertIn("NotAutomatic: yes", get_release(BACKPORTS))
470 self.assertIn("ButAutomaticUpgrades: yes", get_release(BACKPORTS))
471
472+ def testReleaseFileForNotAutomaticProposed(self):
473+ # Test Release file writing for series with NotAutomatic -proposed.
474+ publisher = Publisher(
475+ self.logger, self.config, self.disk_pool,
476+ self.ubuntutest.main_archive)
477+ self.getPubSource(filecontent=b'Hello world', pocket=RELEASE)
478+ self.getPubSource(filecontent=b'Hello world', pocket=PROPOSED)
479+
480+ # Make everything other than breezy-autotest OBSOLETE so that they
481+ # aren't republished.
482+ for series in self.ubuntutest.series:
483+ if series.name != "breezy-autotest":
484+ series.status = SeriesStatus.OBSOLETE
485+
486+ publisher.A_publish(True)
487+ publisher.C_writeIndexes(False)
488+
489+ def get_release(pocket):
490+ release_path = os.path.join(
491+ publisher._config.distsroot,
492+ 'breezy-autotest%s' % pocketsuffix[pocket], 'Release')
493+ with open(release_path) as release_file:
494+ return release_file.read().splitlines()
495+
496+ # When proposed_not_automatic is unset, no Release files have
497+ # NotAutomatic: yes.
498+ self.assertEqual(False, self.breezy_autotest.proposed_not_automatic)
499+ publisher.D_writeReleaseFiles(False)
500+ self.assertNotIn("NotAutomatic: yes", get_release(RELEASE))
501+ self.assertNotIn("NotAutomatic: yes", get_release(PROPOSED))
502+
503+ # But with the flag set, -proposed Release files gain
504+ # NotAutomatic: yes and ButAutomaticUpgrades: yes.
505+ self.breezy_autotest.proposed_not_automatic = True
506+ publisher.D_writeReleaseFiles(False)
507+ self.assertNotIn("NotAutomatic: yes", get_release(RELEASE))
508+ self.assertIn("NotAutomatic: yes", get_release(PROPOSED))
509+ self.assertIn("ButAutomaticUpgrades: yes", get_release(PROPOSED))
510+
511 def testReleaseFileForI18n(self):
512 """Test Release file writing for translated package descriptions."""
513 publisher = Publisher(
514diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
515index f7bab39..31b3938 100644
516--- a/lib/lp/code/browser/configure.zcml
517+++ b/lib/lp/code/browser/configure.zcml
518@@ -963,6 +963,12 @@
519 factory="lp.code.browser.gitrepository.GitRepositoryBreadcrumb"
520 permission="zope.Public"/>
521
522+ <browser:url
523+ for="lp.code.interfaces.gitrepository.IRevisionStatusReport"
524+ path_expression="string:+status/${id}"
525+ attribute_to_parent="git_repository"
526+ rootsite="code"/>
527+
528 <browser:defaultView
529 for="lp.code.interfaces.gitref.IGitRef"
530 name="+index"/>
531diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
532index 92a1b14..ddd79ca 100644
533--- a/lib/lp/code/browser/gitrepository.py
534+++ b/lib/lp/code/browser/gitrepository.py
535@@ -106,6 +106,7 @@ from lp.code.interfaces.gitrepository import (
536 ContributorGitIdentity,
537 IGitRepository,
538 IGitRepositorySet,
539+ IRevisionStatusReportSet,
540 )
541 from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
542 from lp.registry.interfaces.person import (
543@@ -190,6 +191,18 @@ class GitRepositoryNavigation(WebhookTargetNavigationMixin, Navigation):
544
545 usedfor = IGitRepository
546
547+ @stepthrough('+status')
548+ def traverse_status(self, id):
549+ try:
550+ report_id = int(id)
551+ except ValueError:
552+ raise NotFoundError(report_id)
553+ report = getUtility(
554+ IRevisionStatusReportSet).getByID(report_id)
555+ if report is None:
556+ raise NotFoundError(report_id)
557+ return report
558+
559 @stepto("+ref")
560 def traverse_ref(self):
561 segments = list(self.request.getTraversalStack())
562diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
563index 6417c89..fb8e0d5 100644
564--- a/lib/lp/code/configure.zcml
565+++ b/lib/lp/code/configure.zcml
566@@ -953,6 +953,40 @@
567 <allow interface="lp.code.interfaces.gitref.IGitRefRemoteSet" />
568 </securedutility>
569
570+ <!-- RevisionStatusReport -->
571+
572+ <class class="lp.code.model.gitrepository.RevisionStatusReport">
573+ <require
574+ permission="launchpad.View"
575+ interface="lp.code.interfaces.gitrepository.IRevisionStatusReportView
576+ lp.code.interfaces.gitrepository.IRevisionStatusReportEditableAttributes" />
577+ <require
578+ permission="launchpad.Edit"
579+ interface="lp.code.interfaces.gitrepository.IRevisionStatusReportEdit"
580+ set_schema="lp.code.interfaces.gitrepository.IRevisionStatusReportEditableAttributes" />
581+ </class>
582+ <class class="lp.code.model.gitrepository.RevisionStatusArtifact">
583+ <require
584+ permission="launchpad.View"
585+ interface="lp.code.interfaces.gitrepository.IRevisionStatusArtifact" />
586+ </class>
587+ <class class="lp.code.model.gitrepository.RevisionStatusReportSet">
588+ <allow interface="lp.code.interfaces.gitrepository.IRevisionStatusReportSet" />
589+ </class>
590+ <securedutility
591+ class="lp.code.model.gitrepository.RevisionStatusReportSet"
592+ provides="lp.code.interfaces.gitrepository.IRevisionStatusReportSet">
593+ <allow interface="lp.code.interfaces.gitrepository.IRevisionStatusReportSet" />
594+ </securedutility>
595+ <class class="lp.code.model.gitrepository.RevisionStatusArtifactSet">
596+ <allow interface="lp.code.interfaces.gitrepository.IRevisionStatusArtifactSet" />
597+ </class>
598+ <securedutility
599+ class="lp.code.model.gitrepository.RevisionStatusArtifactSet"
600+ provides="lp.code.interfaces.gitrepository.IRevisionStatusArtifactSet">
601+ <allow interface="lp.code.interfaces.gitrepository.IRevisionStatusArtifactSet" />
602+ </securedutility>
603+
604 <!-- Git repository access rules -->
605
606 <class class="lp.code.model.gitrule.GitRule">
607diff --git a/lib/lp/code/enums.py b/lib/lp/code/enums.py
608index 3765754..2dbd287 100644
609--- a/lib/lp/code/enums.py
610+++ b/lib/lp/code/enums.py
611@@ -29,6 +29,8 @@ __all__ = [
612 'GitRepositoryType',
613 'NON_CVS_RCS_TYPES',
614 'RevisionControlSystems',
615+ 'RevisionStatusArtifactType',
616+ 'RevisionStatusResult',
617 'TargetRevisionControlSystems',
618 ]
619
620@@ -252,6 +254,54 @@ class GitPermissionType(EnumeratedType):
621 CAN_FORCE_PUSH = Item("Can force-push")
622
623
624+class RevisionStatusArtifactType(DBEnumeratedType):
625+ LOG = DBItem(0, """
626+ Log
627+
628+ The log produced by the check job.
629+ """)
630+
631+
632+class RevisionStatusResult(DBEnumeratedType):
633+ """Revision Status Result"""
634+
635+ WAITING = DBItem(0, """
636+ Waiting
637+
638+ The check job is waiting to be run.
639+ """)
640+
641+ RUNNING = DBItem(1, """
642+ Running
643+
644+ The check job is currently running.
645+ """)
646+
647+ SUCCEEDED = DBItem(2, """
648+ Succeeded
649+
650+ The check job ran successfully.
651+ """)
652+
653+ FAILED = DBItem(3, """
654+ Failed
655+
656+ The check job failed.
657+ """)
658+
659+ SKIPPED = DBItem(4, """
660+ Skipped
661+
662+ The check job was skipped.
663+ """)
664+
665+ CANCELLED = DBItem(5, """
666+ Cancelled
667+
668+ The check job was cancelled.
669+ """)
670+
671+
672 class BranchLifecycleStatusFilter(EnumeratedType):
673 """Branch Lifecycle Status Filter
674
675diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
676index cb12a5d..aae1b90 100644
677--- a/lib/lp/code/interfaces/gitrepository.py
678+++ b/lib/lp/code/interfaces/gitrepository.py
679@@ -13,9 +13,15 @@ __all__ = [
680 'IGitRepositoryExpensiveRequest',
681 'IGitRepositorySet',
682 'IHasGitRepositoryURL',
683+ 'IRevisionStatusArtifact',
684+ 'IRevisionStatusArtifactSet',
685+ 'IRevisionStatusReport',
686+ 'IRevisionStatusReportSet',
687+ 'RevisionStatusReportsFeatureDisabled',
688 'user_has_special_git_repository_access',
689 ]
690
691+import http.client
692 import re
693 from textwrap import dedent
694
695@@ -23,6 +29,7 @@ from lazr.lifecycle.snapshot import doNotSnapshot
696 from lazr.restful.declarations import (
697 call_with,
698 collection_default_content,
699+ error_status,
700 export_destructor_operation,
701 export_factory_operation,
702 export_operation_as,
703@@ -37,6 +44,7 @@ from lazr.restful.declarations import (
704 operation_returns_collection_of,
705 operation_returns_entry,
706 REQUEST_USER,
707+ scoped,
708 )
709 from lazr.restful.fields import (
710 CollectionField,
711@@ -50,6 +58,7 @@ from zope.interface import (
712 )
713 from zope.schema import (
714 Bool,
715+ Bytes,
716 Choice,
717 Datetime,
718 Int,
719@@ -57,10 +66,12 @@ from zope.schema import (
720 Text,
721 TextLine,
722 )
723+from zope.security.interfaces import Unauthorized
724
725 from lp import _
726 from lp.app.enums import InformationType
727 from lp.app.validators import LaunchpadValidationError
728+from lp.app.validators.attachment import attachment_size_constraint
729 from lp.code.enums import (
730 BranchMergeProposalStatus,
731 BranchSubscriptionDiffSize,
732@@ -69,6 +80,8 @@ from lp.code.enums import (
733 GitListingSort,
734 GitRepositoryStatus,
735 GitRepositoryType,
736+ RevisionStatusArtifactType,
737+ RevisionStatusResult,
738 )
739 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
740 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
741@@ -91,6 +104,7 @@ from lp.services.fields import (
742 InlineObject,
743 PersonChoice,
744 PublicPersonChoice,
745+ URIField,
746 )
747 from lp.services.webhooks.interfaces import IWebhookTarget
748
749@@ -813,6 +827,155 @@ class IGitRepositoryExpensiveRequest(Interface):
750 that is not an admin or a registry expert."""
751
752
753+@error_status(http.client.UNAUTHORIZED)
754+class RevisionStatusReportsFeatureDisabled(Unauthorized):
755+ """Only certain users can access APIs for revision status reports."""
756+
757+ def __init__(self):
758+ super(RevisionStatusReportsFeatureDisabled, self).__init__(
759+ "You do not have permission to create revision status reports")
760+
761+
762+class IRevisionStatusReportView(Interface):
763+ """`IRevisionStatusReport` attributes that require launchpad.View."""
764+
765+ id = Int(title=_("ID"), required=True, readonly=True)
766+
767+ date_created = exported(Datetime(
768+ title=_("When the report was created."), required=True, readonly=True))
769+ date_started = exported(Datetime(
770+ title=_("When the report was started.")), readonly=False)
771+ date_finished = exported(Datetime(
772+ title=_("When the report has finished.")), readonly=False)
773+
774+
775+class IRevisionStatusReportEditableAttributes(Interface):
776+ """`IRevisionStatusReport` attributes that can be edited.
777+
778+ These attributes need launchpad.View to see, and launchpad.Edit to change.
779+ """
780+
781+ title = exported(TextLine(
782+ title=_("A short title for the report."), required=True))
783+
784+ git_repository = exported(Reference(
785+ title=_("The Git repository for which this report is built."),
786+ # Really IGitRepository, patched in _schema_circular_imports.py.
787+ schema=Interface, required=True, readonly=True))
788+
789+ commit_sha1 = exported(TextLine(
790+ title=_("The Git commit for which this report is built."),
791+ required=True, readonly=True))
792+
793+ url = exported(URIField(title=_("URL"), required=False, readonly=True,
794+ description=_("The external url of the report.")))
795+
796+ result_summary = exported(TextLine(
797+ title=_("A short summary of the result."), required=False))
798+
799+ result = exported(Choice(
800+ title=_('Result of the report'), readonly=True,
801+ required=False, vocabulary=RevisionStatusResult))
802+
803+ @mutator_for(result)
804+ @operation_parameters(result=copy_field(result))
805+ @export_write_operation()
806+ @operation_for_version("devel")
807+ def transitionToNewResult(result):
808+ """Set the RevisionStatusReport result.
809+
810+ Set the revision status report result."""
811+
812+
813+class IRevisionStatusReportEdit(Interface):
814+ """`IRevisionStatusReport` attributes that require launchpad.Edit."""
815+
816+ @operation_parameters(
817+ log_data=Bytes(title=_("The content of the artifact in bytes."),
818+ constraint=attachment_size_constraint))
819+ @scoped(AccessTokenScope.REPOSITORY_BUILD_STATUS.title)
820+ @export_write_operation()
821+ @export_operation_as(name="setLog")
822+ @operation_for_version("devel")
823+ def api_setLog(log_data):
824+ """Set a new log on an existing status report.
825+
826+ :param log_data: The contents (in bytes) of the log.
827+ """
828+
829+
830+@exported_as_webservice_entry(as_of="beta")
831+class IRevisionStatusReport(IRevisionStatusReportView,
832+ IRevisionStatusReportEditableAttributes,
833+ IRevisionStatusReportEdit):
834+ """An revision status report for a Git commit."""
835+
836+
837+class IRevisionStatusReportSet(Interface):
838+ """The set of all revision status reports."""
839+
840+ def new(creator, title, git_repository, commit_sha1, date_created=None,
841+ url=None, result_summary=None, result=None, date_started=None,
842+ date_finished=None, log=None):
843+ """Return a new revision status report.
844+
845+ :param title: A text string.
846+ :param git_repository: An `IGitRepository` for which the report
847+ is being created.
848+ :param commit_sha1: The sha1 of the commit for which the report
849+ is being created.
850+ :param date_created: The date when the report is being created.
851+ :param url: External URL to view result of report.
852+ :param result_summary: A short summary of the result.
853+ :param result: The result of the check job for this revision.
854+ :param date_started: DateTime that report was started.
855+ :param date_finished: DateTime that report was completed.
856+ :param log: Stores the content of the artifact for this report.
857+ """
858+
859+ def getByID(id):
860+ """Returns the RevisionStatusReport for a given ID."""
861+
862+ def findByRepository(repository):
863+ """Returns all `RevisionStatusReport` for a repository."""
864+
865+ def findByCommit(repository, commit_sha1):
866+ """Returns all `RevisionStatusReport` for a repository and commit."""
867+
868+
869+class IRevisionStatusArtifactSet(Interface):
870+ """The set of all revision status artifacts."""
871+
872+ def new(lfa, report):
873+ """Return a new revision status artifact.
874+
875+ :param lfa: An `ILibraryFileAlias`.
876+ :param report: An `IRevisionStatusReport` for which the
877+ artifact is being created.
878+ """
879+
880+ def getByID(id):
881+ """Returns the RevisionStatusArtifact for a given ID."""
882+
883+ def findByReport(report):
884+ """Returns the set of artifacts for a given report."""
885+
886+
887+class IRevisionStatusArtifact(Interface):
888+ id = Int(title=_("ID"), required=True, readonly=True)
889+
890+ report = Attribute(
891+ "The `RevisionStatusReport` that this artifact is linked to.")
892+
893+ library_file = Attribute(
894+ "The `LibraryFileAlias` object containing information for "
895+ "a revision status report.")
896+
897+ artifact_type = Choice(
898+ title=_('The type of artifact, only log for now.'),
899+ vocabulary=RevisionStatusArtifactType)
900+
901+
902 class IGitRepositoryEdit(IWebhookTarget, IAccessTokenTarget):
903 """IGitRepository methods that require launchpad.Edit permission."""
904
905@@ -1015,6 +1178,38 @@ class IGitRepositoryEdit(IWebhookTarget, IAccessTokenTarget):
906 :raise: CannotDeleteGitRepository if the repository cannot be deleted.
907 """
908
909+ @operation_parameters(
910+ title=copy_field(IRevisionStatusReport["title"]),
911+ commit_sha1=copy_field(IRevisionStatusReport["commit_sha1"]),
912+ url=copy_field(IRevisionStatusReport["url"]),
913+ result_summary=copy_field(IRevisionStatusReport["result_summary"]),
914+ result=copy_field(IRevisionStatusReport["result"]))
915+ @scoped(AccessTokenScope.REPOSITORY_BUILD_STATUS.title)
916+ @call_with(user=REQUEST_USER)
917+ @export_factory_operation(IRevisionStatusReport, [])
918+ @operation_for_version("devel")
919+ def newStatusReport(title, commit_sha1, url, result_summary, result, user):
920+ """Create a new status report.
921+
922+ :param title: The name of the new report.
923+ :param commit_sha1: The commit sha1 for the report.
924+ :param url: The external link of the status report.
925+ :param result_summary: The description of the new report.
926+ :param result: The result of the new report.
927+ """
928+
929+ @operation_parameters(
930+ commit_sha1=copy_field(IRevisionStatusReport["commit_sha1"]))
931+ @scoped(AccessTokenScope.REPOSITORY_BUILD_STATUS.title)
932+ @operation_returns_collection_of(Interface)
933+ @export_read_operation()
934+ @operation_for_version("devel")
935+ def getStatusReports(commit_sha1):
936+ """Retrieves the list of reports that exist for a commit.
937+
938+ :param commit_sha1: The commit sha1 for the report.
939+ """
940+
941
942 # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
943 # generation working. Individual attributes must set their version to
944diff --git a/lib/lp/code/interfaces/webservice.py b/lib/lp/code/interfaces/webservice.py
945index 02546e9..bd94298 100644
946--- a/lib/lp/code/interfaces/webservice.py
947+++ b/lib/lp/code/interfaces/webservice.py
948@@ -31,6 +31,7 @@ __all__ = [
949 'IGitSubscription',
950 'IHasGitRepositories',
951 'IPreviewDiff',
952+ 'IRevisionStatusReport',
953 'ISourcePackageRecipe',
954 'ISourcePackageRecipeBuild',
955 ]
956@@ -66,6 +67,7 @@ from lp.code.interfaces.gitref import IGitRef
957 from lp.code.interfaces.gitrepository import (
958 IGitRepository,
959 IGitRepositorySet,
960+ IRevisionStatusReport,
961 )
962 from lp.code.interfaces.gitsubscription import IGitSubscription
963 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
964diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
965index 812f1d7..43a005a 100644
966--- a/lib/lp/code/model/gitrepository.py
967+++ b/lib/lp/code/model/gitrepository.py
968@@ -6,6 +6,7 @@ __all__ = [
969 'GitRepository',
970 'GitRepositorySet',
971 'parse_git_commits',
972+ 'RevisionStatusReport',
973 ]
974
975 from collections import (
976@@ -19,6 +20,7 @@ from datetime import (
977 import email
978 from fnmatch import fnmatch
979 from functools import partial
980+import io
981 from itertools import (
982 chain,
983 groupby,
984@@ -102,6 +104,8 @@ from lp.code.enums import (
985 GitPermissionType,
986 GitRepositoryStatus,
987 GitRepositoryType,
988+ RevisionStatusArtifactType,
989+ RevisionStatusResult,
990 )
991 from lp.code.errors import (
992 CannotDeleteGitRepository,
993@@ -131,6 +135,10 @@ from lp.code.interfaces.gitrepository import (
994 GitIdentityMixin,
995 IGitRepository,
996 IGitRepositorySet,
997+ IRevisionStatusArtifactSet,
998+ IRevisionStatusReport,
999+ IRevisionStatusReportSet,
1000+ RevisionStatusReportsFeatureDisabled,
1001 user_has_special_git_repository_access,
1002 )
1003 from lp.code.interfaces.gitrule import (
1004@@ -205,6 +213,7 @@ from lp.services.identity.interfaces.account import (
1005 )
1006 from lp.services.job.interfaces.job import JobStatus
1007 from lp.services.job.model.job import Job
1008+from lp.services.librarian.interfaces import ILibraryFileAliasSet
1009 from lp.services.macaroons.interfaces import IMacaroonIssuer
1010 from lp.services.macaroons.model import MacaroonIssuerBase
1011 from lp.services.mail.notificationrecipientset import NotificationRecipientSet
1012@@ -219,8 +228,9 @@ from lp.services.webhooks.model import WebhookTargetMixin
1013 from lp.snappy.interfaces.snap import ISnapSet
1014
1015
1016-logger = logging.getLogger(__name__)
1017+REVISION_STATUS_REPORT_ALLOW_CREATE = 'revision_status_report.allow_create'
1018
1019+logger = logging.getLogger(__name__)
1020
1021 object_type_map = {
1022 "commit": GitObjectType.COMMIT,
1023@@ -292,6 +302,139 @@ def git_repository_modified(repository, event):
1024 send_git_repository_modified_notifications(repository, event)
1025
1026
1027+@implementer(IRevisionStatusReport)
1028+class RevisionStatusReport(StormBase):
1029+ __storm_table__ = 'RevisionStatusReport'
1030+
1031+ id = Int(primary=True)
1032+
1033+ creator_id = Int(name="creator", allow_none=False)
1034+ creator = Reference(creator_id, "Person.id")
1035+
1036+ title = Unicode(name='name', allow_none=False)
1037+
1038+ git_repository_id = Int(name='git_repository', allow_none=False)
1039+ git_repository = Reference(git_repository_id, 'GitRepository.id')
1040+
1041+ commit_sha1 = Unicode(name='commit_sha1', allow_none=False)
1042+
1043+ url = Unicode(name='url', allow_none=True)
1044+
1045+ result_summary = Unicode(name='description', allow_none=True)
1046+
1047+ result = DBEnum(name='result', allow_none=True, enum=RevisionStatusResult)
1048+
1049+ date_created = DateTime(
1050+ name='date_created', tzinfo=pytz.UTC, allow_none=False)
1051+
1052+ date_started = DateTime(name='date_started', tzinfo=pytz.UTC,
1053+ allow_none=True)
1054+ date_finished = DateTime(name='date_finished', tzinfo=pytz.UTC,
1055+ allow_none=True)
1056+
1057+ def __init__(self, git_repository, user, title, commit_sha1,
1058+ url, result_summary, result):
1059+ super().__init__()
1060+ self.creator = user
1061+ self.git_repository = git_repository
1062+ self.title = title
1063+ self.commit_sha1 = commit_sha1
1064+ self.url = url
1065+ self.result_summary = result_summary
1066+ self.result = result
1067+ self.date_created = UTC_NOW
1068+
1069+ def api_setLog(self, log_data):
1070+ filename = '%s-%s.txt' % (self.title, self.commit_sha1)
1071+
1072+ lfa = getUtility(ILibraryFileAliasSet).create(
1073+ name=filename, size=len(log_data),
1074+ file=io.BytesIO(log_data), contentType='text/plain')
1075+
1076+ getUtility(IRevisionStatusArtifactSet).new(lfa, self)
1077+
1078+ def transitionToNewResult(self, result):
1079+ if self.result == RevisionStatusResult.WAITING:
1080+ if result == RevisionStatusResult.RUNNING:
1081+ self.date_started == UTC_NOW
1082+ else:
1083+ self.date_finished = UTC_NOW
1084+ self.result = result
1085+
1086+
1087+@implementer(IRevisionStatusReportSet)
1088+class RevisionStatusReportSet:
1089+
1090+ def new(self, creator, title, git_repository, commit_sha1,
1091+ url=None, result_summary=None, result=None,
1092+ date_started=None, date_finished=None, log=None):
1093+ """See `IRevisionStatusReportSet`."""
1094+ store = IStore(RevisionStatusReport)
1095+ report = RevisionStatusReport(git_repository, creator, title,
1096+ commit_sha1, url, result_summary,
1097+ result)
1098+ store.add(report)
1099+ return report
1100+
1101+ def getByID(self, id):
1102+ return IStore(
1103+ RevisionStatusReport).find(RevisionStatusReport, id=id).one()
1104+
1105+ def findByRepository(self, repository):
1106+ return IStore(RevisionStatusReport).find(
1107+ RevisionStatusReport,
1108+ RevisionStatusReport.git_repository == repository)
1109+
1110+ def findByCommit(self, repository, commit_sha1):
1111+ """Returns all `RevisionStatusReport` for a repository and commit."""
1112+ return IStore(RevisionStatusReport).find(
1113+ RevisionStatusReport,
1114+ git_repository=repository,
1115+ commit_sha1=commit_sha1)
1116+
1117+
1118+class RevisionStatusArtifact(StormBase):
1119+ __storm_table__ = 'RevisionStatusArtifact'
1120+
1121+ id = Int(primary=True)
1122+
1123+ library_file_id = Int(name='library_file', allow_none=False)
1124+ library_file = Reference(library_file_id, 'LibraryFileAlias.id')
1125+
1126+ report_id = Int(name='report', allow_none=False)
1127+ report = Reference(report_id, 'RevisionStatusReport.id')
1128+
1129+ artifact_type = DBEnum(name='type', allow_none=False,
1130+ enum=RevisionStatusArtifactType)
1131+
1132+ def __init__(self, library_file, report):
1133+ super().__init__()
1134+ self.library_file = library_file
1135+ self.report = report
1136+ self.artifact_type = RevisionStatusArtifactType.LOG
1137+
1138+
1139+@implementer(IRevisionStatusArtifactSet)
1140+class RevisionStatusArtifactSet:
1141+
1142+ def new(self, lfa, report):
1143+ """See `IRevisionStatusArtifactSet`."""
1144+ store = IStore(RevisionStatusArtifact)
1145+ artifact = RevisionStatusArtifact(lfa, report)
1146+ store.add(artifact)
1147+ return artifact
1148+
1149+ def getById(self, id):
1150+ return IStore(RevisionStatusArtifact).find(
1151+ RevisionStatusArtifact,
1152+ RevisionStatusArtifact.id == id).one()
1153+
1154+ def findByReport(self, report):
1155+ return IStore(RevisionStatusArtifact).find(
1156+ RevisionStatusArtifact,
1157+ RevisionStatusArtifact.report == report)
1158+
1159+
1160 @implementer(IGitRepository, IHasOwner, IPrivacy, IInformationType)
1161 class GitRepository(StormBase, WebhookTargetMixin, AccessTokenTargetMixin,
1162 GitIdentityMixin):
1163@@ -501,6 +644,20 @@ class GitRepository(StormBase, WebhookTargetMixin, AccessTokenTargetMixin,
1164 def collectGarbage(self):
1165 getUtility(IGitHostingClient).collectGarbage(self.getInternalPath())
1166
1167+ def newStatusReport(self, user, title, commit_sha1, url=None,
1168+ result_summary=None, result=None):
1169+
1170+ if not getFeatureFlag(REVISION_STATUS_REPORT_ALLOW_CREATE):
1171+ raise RevisionStatusReportsFeatureDisabled()
1172+
1173+ report = RevisionStatusReport(self, user, title, commit_sha1,
1174+ url, result_summary, result)
1175+ return report
1176+
1177+ def getStatusReports(self, commit_sha1):
1178+ return getUtility(
1179+ IRevisionStatusReportSet).findByCommit(self, commit_sha1)
1180+
1181 @property
1182 def namespace(self):
1183 """See `IGitRepository`."""
1184diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
1185index 252b535..6a759be 100644
1186--- a/lib/lp/code/model/tests/test_gitrepository.py
1187+++ b/lib/lp/code/model/tests/test_gitrepository.py
1188@@ -10,6 +10,7 @@ from datetime import (
1189 import email
1190 from functools import partial
1191 import hashlib
1192+import io
1193 import json
1194
1195 from breezy import urlutils
1196@@ -65,6 +66,7 @@ from lp.code.enums import (
1197 GitObjectType,
1198 GitRepositoryStatus,
1199 GitRepositoryType,
1200+ RevisionStatusResult,
1201 TargetRevisionControlSystems,
1202 )
1203 from lp.code.errors import (
1204@@ -95,6 +97,8 @@ from lp.code.interfaces.gitrepository import (
1205 IGitRepository,
1206 IGitRepositorySet,
1207 IGitRepositoryView,
1208+ IRevisionStatusArtifactSet,
1209+ IRevisionStatusReportSet,
1210 )
1211 from lp.code.interfaces.gitrule import (
1212 IGitNascentRule,
1213@@ -121,6 +125,7 @@ from lp.code.model.gitrepository import (
1214 DeletionCallable,
1215 DeletionOperation,
1216 GitRepository,
1217+ REVISION_STATUS_REPORT_ALLOW_CREATE,
1218 )
1219 from lp.code.tests.helpers import GitHostingFixture
1220 from lp.code.xmlrpc.git import GitAPI
1221@@ -571,6 +576,24 @@ class TestGitRepository(TestCaseWithFactory):
1222 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
1223 self.assertEqual(7, recorder1.count)
1224
1225+ def test_findRevisionStatusReport(self):
1226+ repository = removeSecurityProxy(self.factory.makeGitRepository())
1227+ title = self.factory.getUniqueUnicode('report-title')
1228+ commit_sha1 = hashlib.sha1(b"Some content").hexdigest()
1229+ result_summary = "120/120 tests passed"
1230+
1231+ report = self.factory.makeRevisionStatusReport(
1232+ user=repository.owner, git_repository=repository,
1233+ title=title, commit_sha1=commit_sha1,
1234+ result_summary=result_summary,
1235+ result=RevisionStatusResult.SUCCEEDED)
1236+
1237+ with person_logged_in(repository.owner):
1238+ result = getUtility(
1239+ IRevisionStatusReportSet).getByID(
1240+ report.id)
1241+ self.assertEqual(report, result)
1242+
1243
1244 class TestGitIdentityMixin(TestCaseWithFactory):
1245 """Test the defaults and identities provided by GitIdentityMixin."""
1246@@ -4241,6 +4264,126 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
1247 self.assertEqual(
1248 InformationType.PUBLIC, repository_db.information_type)
1249
1250+ def test_newRevisionStatusReport_featureFlagDisabled(self):
1251+ repository = self.factory.makeGitRepository()
1252+ requester = repository.owner
1253+ webservice = webservice_for_person(None, default_api_version="devel")
1254+ with person_logged_in(requester):
1255+ repository_url = api_url(repository)
1256+
1257+ secret, _ = self.factory.makeAccessToken(
1258+ owner=requester, target=repository,
1259+ scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
1260+ header = {'Authorization': 'Token %s' % secret}
1261+
1262+ response = webservice.named_post(
1263+ repository_url, "newStatusReport",
1264+ headers=header, title="CI",
1265+ commit_sha1=hashlib.sha1(
1266+ self.factory.getUniqueBytes()).hexdigest(),
1267+ url='https://launchpad.net/',
1268+ result_summary="120/120 tests passed",
1269+ result="Succeeded")
1270+
1271+ self.assertEqual(401, response.status)
1272+ self.assertIn(
1273+ b'You do not have permission to create revision status reports',
1274+ response.body)
1275+
1276+ def test_newRevisionStatusReport(self):
1277+ repository = self.factory.makeGitRepository()
1278+ requester = repository.owner
1279+ webservice = webservice_for_person(None, default_api_version="devel")
1280+ with person_logged_in(requester):
1281+ repository_url = api_url(repository)
1282+
1283+ secret, _ = self.factory.makeAccessToken(
1284+ owner=requester, target=repository,
1285+ scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
1286+ header = {'Authorization': 'Token %s' % secret}
1287+
1288+ self.useFixture(FeatureFixture(
1289+ {REVISION_STATUS_REPORT_ALLOW_CREATE: "on"}))
1290+
1291+ response = webservice.named_post(
1292+ repository_url, "newStatusReport",
1293+ headers=header, title="CI",
1294+ commit_sha1=hashlib.sha1(
1295+ self.factory.getUniqueBytes()).hexdigest(),
1296+ url='https://launchpad.net/',
1297+ result_summary="120/120 tests passed",
1298+ result="Succeeded")
1299+ self.assertEqual(201, response.status)
1300+
1301+ with person_logged_in(requester):
1302+ results = getUtility(
1303+ IRevisionStatusReportSet).findByRepository(repository)
1304+ reports = list(results)
1305+ urls = [webservice.getAbsoluteUrl('%s/+status/%s' % (
1306+ api_url(repository),
1307+ report.id)) for report in reports]
1308+ self.assertIn(response.getHeader("Location"), urls)
1309+
1310+ def test_getRevisionStatusReports(self):
1311+ repository = self.factory.makeGitRepository()
1312+ repository2 = self.factory.makeGitRepository()
1313+ requester = repository.owner
1314+ title = self.factory.getUniqueUnicode('report-title')
1315+ result_summary = "120/120 tests passed"
1316+ commit_sha1s = [hashlib.sha1(
1317+ self.factory.getUniqueBytes()).hexdigest() for _ in range(2)]
1318+
1319+ result_summary2 = "Lint"
1320+ title2 = "Invalid import in test_file.py"
1321+
1322+ report1 = self.factory.makeRevisionStatusReport(
1323+ user=repository.owner, git_repository=repository,
1324+ title=title, commit_sha1=commit_sha1s[0],
1325+ result_summary=result_summary,
1326+ result=RevisionStatusResult.SUCCEEDED)
1327+
1328+ report2 = self.factory.makeRevisionStatusReport(
1329+ user=repository.owner, git_repository=repository,
1330+ title=title2, commit_sha1=commit_sha1s[0],
1331+ result_summary=result_summary2,
1332+ result=RevisionStatusResult.FAILED)
1333+
1334+ self.factory.makeRevisionStatusReport(
1335+ user=repository.owner, git_repository=repository,
1336+ title=title2, commit_sha1=commit_sha1s[1],
1337+ result_summary=result_summary2,
1338+ result=RevisionStatusResult.FAILED)
1339+
1340+ self.factory.makeRevisionStatusReport(
1341+ user=repository.owner, git_repository=repository2,
1342+ title=title2, commit_sha1=commit_sha1s[0],
1343+ result_summary=result_summary2,
1344+ result=RevisionStatusResult.FAILED)
1345+
1346+ webservice = webservice_for_person(None, default_api_version="devel")
1347+ with person_logged_in(requester):
1348+ repository_url = api_url(repository)
1349+
1350+ secret, _ = self.factory.makeAccessToken(
1351+ owner=requester, target=repository,
1352+ scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
1353+ header = {'Authorization': 'Token %s' % secret}
1354+
1355+ self.useFixture(FeatureFixture(
1356+ {REVISION_STATUS_REPORT_ALLOW_CREATE: "on"}))
1357+
1358+ response = webservice.named_get(
1359+ repository_url, "getStatusReports",
1360+ headers=header,
1361+ commit_sha1=commit_sha1s[0])
1362+ self.assertEqual(200, response.status)
1363+ with person_logged_in(requester):
1364+ result = getUtility(
1365+ IRevisionStatusReportSet).findByCommit(
1366+ repository, commit_sha1s[0])
1367+
1368+ self.assertContentEqual([report1, report2], result)
1369+
1370 def test_set_target(self):
1371 # The repository owner can move the repository to another target;
1372 # this redirects to the new location.
1373@@ -4831,6 +4974,60 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
1374 response.body)
1375
1376
1377+class TestRevisionStatusReportWebservice(TestCaseWithFactory):
1378+ layer = LaunchpadFunctionalLayer
1379+
1380+ def setUp(self):
1381+ super(TestRevisionStatusReportWebservice, self).setUp()
1382+ self.repository = self.factory.makeGitRepository()
1383+ self.requester = self.repository.owner
1384+ title = self.factory.getUniqueUnicode('report-title')
1385+ commit_sha1 = hashlib.sha1(b"Some content").hexdigest()
1386+ result_summary = "120/120 tests passed"
1387+
1388+ self.report = self.factory.makeRevisionStatusReport(
1389+ user=self.repository.owner, git_repository=self.repository,
1390+ title=title, commit_sha1=commit_sha1,
1391+ result_summary=result_summary,
1392+ result=RevisionStatusResult.SUCCEEDED)
1393+
1394+ self.webservice = webservice_for_person(
1395+ None, default_api_version="devel")
1396+ with person_logged_in(self.requester):
1397+ self.report_url = api_url(self.report)
1398+
1399+ secret, _ = self.factory.makeAccessToken(
1400+ owner=self.requester, target=self.repository,
1401+ scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
1402+ self.header = {'Authorization': 'Token %s' % secret}
1403+
1404+ def test_setLogOnRevisionStatusReport(self):
1405+ content = b'log_content_data'
1406+ filesize = len(content)
1407+ sha1 = hashlib.sha1(content).hexdigest()
1408+ md5 = hashlib.md5(content).hexdigest()
1409+ response = self.webservice.named_post(
1410+ self.report_url, "setLog",
1411+ headers=self.header,
1412+ log_data=io.BytesIO(content))
1413+ self.assertEqual(200, response.status)
1414+
1415+ # A report may have multiple artifacts.
1416+ # We verify that the content we just submitted via API now
1417+ # matches one of the artifacts in the DB for the report.
1418+ with person_logged_in(self.requester):
1419+ artifacts = list(getUtility(
1420+ IRevisionStatusArtifactSet).findByReport(self.report))
1421+ lfcs = [artifact.library_file.content for artifact in artifacts]
1422+ sha1_of_all_artifacts = [lfc.sha1 for lfc in lfcs]
1423+ md5_of_all_artifacts = [lfc.md5 for lfc in lfcs]
1424+ filesizes_of_all_artifacts = [lfc.filesize for lfc in lfcs]
1425+
1426+ self.assertIn(sha1, sha1_of_all_artifacts)
1427+ self.assertIn(md5, md5_of_all_artifacts)
1428+ self.assertIn(filesize, filesizes_of_all_artifacts)
1429+
1430+
1431 class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
1432 """Test GitRepository macaroon issuing and verification."""
1433
1434diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
1435index 9d07930..0ccf396 100644
1436--- a/lib/lp/registry/configure.zcml
1437+++ b/lib/lp/registry/configure.zcml
1438@@ -1,4 +1,4 @@
1439-<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
1440+<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the
1441 GNU Affero General Public License version 3 (see the file LICENSE).
1442 -->
1443
1444@@ -326,6 +326,7 @@
1445 description
1446 driver
1447 backports_not_automatic
1448+ proposed_not_automatic
1449 include_long_descriptions
1450 index_compressors
1451 publish_by_hash
1452diff --git a/lib/lp/registry/doc/distribution-mirror.txt b/lib/lp/registry/doc/distribution-mirror.txt
1453index 8939a83..2c7cac5 100644
1454--- a/lib/lp/registry/doc/distribution-mirror.txt
1455+++ b/lib/lp/registry/doc/distribution-mirror.txt
1456@@ -367,11 +367,14 @@ up on the public mirror listings.
1457 >>> import email
1458 >>> from lp.services.mail import stub
1459 >>> len(stub.test_emails)
1460- 2
1461+ 3
1462 >>> stub.test_emails.sort(key=lambda e: sorted(e[1])) # sort by to_addr
1463 >>> from_addr, to_addrs, raw_message = stub.test_emails.pop(0)
1464 >>> print(pretty(sorted(to_addrs)))
1465- ['karl@canonical.com', 'mark@example.com']
1466+ ['karl@canonical.com']
1467+ >>> from_addr, to_addrs, raw_message = stub.test_emails.pop(0)
1468+ >>> print(pretty(sorted(to_addrs)))
1469+ ['mark@example.com']
1470 >>> from_addr, to_addrs, raw_message = stub.test_emails.pop(0)
1471 >>> print(pretty(sorted(to_addrs)))
1472 ['mark@example.com']
1473@@ -401,10 +404,14 @@ single notification to the distribution's mirror admins.
1474 >>> valid_mirror.disable(notify_owner=False, log=log)
1475 >>> transaction.commit()
1476 >>> len(stub.test_emails)
1477- 1
1478- >>> from_addr, to_addrs, raw_message = stub.test_emails.pop()
1479+ 2
1480+ >>> stub.test_emails.sort(key=lambda e: sorted(e[1])) # sort by to_addr
1481+ >>> from_addr, to_addrs, raw_message = stub.test_emails.pop(0)
1482 >>> print(pretty(sorted(to_addrs)))
1483- ['karl@canonical.com', 'mark@example.com']
1484+ ['karl@canonical.com']
1485+ >>> from_addr, to_addrs, raw_message = stub.test_emails.pop(0)
1486+ >>> print(pretty(sorted(to_addrs)))
1487+ ['mark@example.com']
1488
1489 Now we delete the MirrorProbeRecord we've just created, to make
1490 sure this mirror is probed by our prober script.
1491diff --git a/lib/lp/registry/interfaces/distroseries.py b/lib/lp/registry/interfaces/distroseries.py
1492index 848cf6d..da0e19e 100644
1493--- a/lib/lp/registry/interfaces/distroseries.py
1494+++ b/lib/lp/registry/interfaces/distroseries.py
1495@@ -1,4 +1,4 @@
1496-# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
1497+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
1498 # GNU Affero General Public License version 3 (see the file LICENSE).
1499
1500 """Interfaces including and related to IDistroSeries."""
1501@@ -373,6 +373,15 @@ class IDistroSeriesPublic(
1502 automatically upgrade within backports, but not into it.
1503 """))
1504
1505+ proposed_not_automatic = Bool(
1506+ title=_("Don't upgrade to proposed updates automatically"),
1507+ required=True,
1508+ description=_("""
1509+ Set NotAutomatic: yes and ButAutomaticUpgrades: yes in Release
1510+ files generated for the proposed pocket. This tells apt to
1511+ automatically upgrade within proposed, but not into it.
1512+ """))
1513+
1514 include_long_descriptions = exported(
1515 Bool(
1516 title=_(
1517diff --git a/lib/lp/registry/model/distributionmirror.py b/lib/lp/registry/model/distributionmirror.py
1518index 8d3bd82..2454187 100644
1519--- a/lib/lp/registry/model/distributionmirror.py
1520+++ b/lib/lp/registry/model/distributionmirror.py
1521@@ -385,13 +385,14 @@ class DistributionMirror(SQLBase):
1522 message = template % replacements
1523 subject = "Launchpad: Verification of %s failed" % self.name
1524
1525- mirror_admin_address = get_contact_email_addresses(
1526+ mirror_admin_addresses = get_contact_email_addresses(
1527 self.distribution.mirror_admin)
1528- simple_sendmail(fromaddress, mirror_admin_address, subject, message)
1529+ for admin_address in mirror_admin_addresses:
1530+ simple_sendmail(fromaddress, admin_address, subject, message)
1531
1532 if notify_owner:
1533- owner_address = get_contact_email_addresses(self.owner)
1534- if len(owner_address) > 0:
1535+ owner_addresses = get_contact_email_addresses(self.owner)
1536+ for owner_address in owner_addresses:
1537 simple_sendmail(fromaddress, owner_address, subject, message)
1538
1539 def newProbeRecord(self, log_file):
1540diff --git a/lib/lp/registry/model/distroseries.py b/lib/lp/registry/model/distroseries.py
1541index 2ad609c..167aa8e 100644
1542--- a/lib/lp/registry/model/distroseries.py
1543+++ b/lib/lp/registry/model/distroseries.py
1544@@ -279,6 +279,7 @@ class DistroSeries(SQLBase, BugTargetBase, HasSpecificationsMixin,
1545 if "publishing_options" not in kwargs:
1546 kwargs["publishing_options"] = {
1547 "backports_not_automatic": False,
1548+ "proposed_not_automatic": False,
1549 "include_long_descriptions": True,
1550 "index_compressors": [
1551 compressor.title
1552@@ -828,6 +829,15 @@ class DistroSeries(SQLBase, BugTargetBase, HasSpecificationsMixin,
1553 self.publishing_options["backports_not_automatic"] = value
1554
1555 @property
1556+ def proposed_not_automatic(self):
1557+ return self.publishing_options.get("proposed_not_automatic", False)
1558+
1559+ @proposed_not_automatic.setter
1560+ def proposed_not_automatic(self, value):
1561+ assert isinstance(value, bool)
1562+ self.publishing_options["proposed_not_automatic"] = value
1563+
1564+ @property
1565 def include_long_descriptions(self):
1566 return self.publishing_options.get("include_long_descriptions", True)
1567
1568diff --git a/lib/lp/registry/model/person.py b/lib/lp/registry/model/person.py
1569index 36be643..ad7fd53 100644
1570--- a/lib/lp/registry/model/person.py
1571+++ b/lib/lp/registry/model/person.py
1572@@ -2661,7 +2661,7 @@ class Person(
1573 for address in all_addresses:
1574 # Delete all email addresses that are not the preferred email
1575 # address, or the team's email address. If this method was called
1576- # with None, and there is no mailing list, then this condidition
1577+ # with None, and there is no mailing list, then this condition
1578 # is (None, None), causing all email addresses to be deleted.
1579 if address not in (email, mailing_list_email):
1580 address.destroySelf()
1581diff --git a/lib/lp/registry/tests/test_distributionmirror.py b/lib/lp/registry/tests/test_distributionmirror.py
1582index 08dae43..8f1423d 100644
1583--- a/lib/lp/registry/tests/test_distributionmirror.py
1584+++ b/lib/lp/registry/tests/test_distributionmirror.py
1585@@ -170,14 +170,22 @@ class TestDistributionMirror(TestCaseWithFactory):
1586 mirror.disable(notify_owner=True, log=log)
1587 # A notification was sent to the owner and other to the mirror admins.
1588 transaction.commit()
1589- self.assertEqual(len(stub.test_emails), 2)
1590+ self.assertEqual(len(stub.test_emails), 3)
1591+
1592+ # In order to prevent data disclosure, emails have to be sent to one
1593+ # person each, ie it is not allowed to have multiple recipients in an
1594+ # email's `to` field.
1595+ for email in stub.test_emails:
1596+ number_of_to_addresses = len(email[1])
1597+ self.assertLess(number_of_to_addresses, 2)
1598+
1599 stub.test_emails = []
1600
1601 mirror.disable(notify_owner=True, log=log)
1602 # Again, a notification was sent to the owner and other to the mirror
1603 # admins.
1604 transaction.commit()
1605- self.assertEqual(len(stub.test_emails), 2)
1606+ self.assertEqual(len(stub.test_emails), 3)
1607 stub.test_emails = []
1608
1609 # For mirrors that have been probed more than once, we'll only notify
1610@@ -187,7 +195,7 @@ class TestDistributionMirror(TestCaseWithFactory):
1611 mirror.disable(notify_owner=True, log=log)
1612 # A notification was sent to the owner and other to the mirror admins.
1613 transaction.commit()
1614- self.assertEqual(len(stub.test_emails), 2)
1615+ self.assertEqual(len(stub.test_emails), 3)
1616 stub.test_emails = []
1617
1618 # We can always disable notifications to the owner by passing
1619@@ -195,7 +203,7 @@ class TestDistributionMirror(TestCaseWithFactory):
1620 mirror.enabled = True
1621 mirror.disable(notify_owner=False, log=log)
1622 transaction.commit()
1623- self.assertEqual(len(stub.test_emails), 1)
1624+ self.assertEqual(len(stub.test_emails), 2)
1625 stub.test_emails = []
1626
1627 mirror.enabled = False
1628@@ -217,12 +225,12 @@ class TestDistributionMirror(TestCaseWithFactory):
1629 transaction.commit()
1630 stub.test_emails = []
1631
1632- # Disabling the mirror results in a single notification to the
1633- # mirror admins.
1634+ # Disabling the mirror results in one notification for each of
1635+ # the three mirror admins.
1636 self.factory.makeMirrorProbeRecord(mirror)
1637 mirror.disable(notify_owner=True, log="It broke.")
1638 transaction.commit()
1639- self.assertEqual(len(stub.test_emails), 1)
1640+ self.assertEqual(len(stub.test_emails), 3)
1641
1642 def test_permissions_for_resubmit(self):
1643 self.assertRaises(
1644diff --git a/lib/lp/registry/tests/test_distroseries.py b/lib/lp/registry/tests/test_distroseries.py
1645index 1543eca..d67e499 100644
1646--- a/lib/lp/registry/tests/test_distroseries.py
1647+++ b/lib/lp/registry/tests/test_distroseries.py
1648@@ -1,4 +1,4 @@
1649-# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
1650+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
1651 # GNU Affero General Public License version 3 (see the file LICENSE).
1652
1653 """Tests for distroseries."""
1654@@ -358,6 +358,16 @@ class TestDistroSeries(TestCaseWithFactory):
1655 self.assertTrue(
1656 naked_distroseries.publishing_options["backports_not_automatic"])
1657
1658+ def test_proposed_not_automatic(self):
1659+ distroseries = self.factory.makeDistroSeries()
1660+ self.assertFalse(distroseries.proposed_not_automatic)
1661+ with admin_logged_in():
1662+ distroseries.proposed_not_automatic = True
1663+ self.assertTrue(distroseries.proposed_not_automatic)
1664+ naked_distroseries = removeSecurityProxy(distroseries)
1665+ self.assertTrue(
1666+ naked_distroseries.publishing_options["proposed_not_automatic"])
1667+
1668 def test_include_long_descriptions(self):
1669 distroseries = self.factory.makeDistroSeries()
1670 self.assertTrue(distroseries.include_long_descriptions)
1671diff --git a/lib/lp/security.py b/lib/lp/security.py
1672index 8f66fbb..8435167 100644
1673--- a/lib/lp/security.py
1674+++ b/lib/lp/security.py
1675@@ -99,6 +99,8 @@ from lp.code.interfaces.gitcollection import IGitCollection
1676 from lp.code.interfaces.gitref import IGitRef
1677 from lp.code.interfaces.gitrepository import (
1678 IGitRepository,
1679+ IRevisionStatusArtifact,
1680+ IRevisionStatusReport,
1681 user_has_special_git_repository_access,
1682 )
1683 from lp.code.interfaces.gitrule import (
1684@@ -683,6 +685,23 @@ class EditSpecificationByRelatedPeople(AuthorizationBase):
1685 self.obj, ['owner', 'drafter', 'assignee', 'approver']))
1686
1687
1688+class EditRevisionStatusReport(AuthorizationBase):
1689+ """The owner of a Git repository can edit its status reports."""
1690+ permission = 'launchpad.Edit'
1691+ usedfor = IRevisionStatusReport
1692+
1693+ def checkAuthenticated(self, user):
1694+ return user.isOwner(self.obj.git_repository)
1695+
1696+
1697+class EditRevisionStatusArtifact(DelegatedAuthorization):
1698+ permission = 'launchpad.Edit'
1699+ usedfor = IRevisionStatusArtifact
1700+
1701+ def __init__(self, obj):
1702+ super().__init__(obj, obj.report, 'launchpad.Edit')
1703+
1704+
1705 class AdminSpecification(AuthorizationBase):
1706 permission = 'launchpad.Admin'
1707 usedfor = ISpecification
1708diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
1709index 5472dbc..73bfb70 100644
1710--- a/lib/lp/services/config/schema-lazr.conf
1711+++ b/lib/lp/services/config/schema-lazr.conf
1712@@ -1332,6 +1332,10 @@ old_os_tenant_name: none
1713 # datatype: string
1714 old_os_auth_version: 2.0
1715
1716+# Time in seconds to wait for a response from Swift.
1717+# datatype: integer
1718+swift_timeout: 15
1719+
1720
1721 # Mailman configuration. Most of this is configured in
1722 # https://git.launchpad.net/lp-mailman instead; the entries here are only
1723diff --git a/lib/lp/services/librarianserver/swift.py b/lib/lp/services/librarianserver/swift.py
1724index d844d50..4a1139d 100644
1725--- a/lib/lp/services/librarianserver/swift.py
1726+++ b/lib/lp/services/librarianserver/swift.py
1727@@ -426,6 +426,7 @@ class ConnectionPool:
1728 key=self.os_password,
1729 tenant_name=self.os_tenant_name,
1730 auth_version=self.os_auth_version,
1731+ timeout=float(config.librarian_server.swift_timeout),
1732 )
1733
1734
1735diff --git a/lib/lp/services/librarianserver/tests/test_swift.py b/lib/lp/services/librarianserver/tests/test_swift.py
1736index 98595b6..ecdb6d9 100644
1737--- a/lib/lp/services/librarianserver/tests/test_swift.py
1738+++ b/lib/lp/services/librarianserver/tests/test_swift.py
1739@@ -363,6 +363,15 @@ class TestFeedSwift(TestCase):
1740 finally:
1741 swift_client.close()
1742
1743+ def test_swift_timeout(self):
1744+ # The librarian's Swift connections honour the configured timeout.
1745+ self.pushConfig(
1746+ 'librarian_server', os_username='timeout', swift_timeout=0.1)
1747+ swift_client = self.swift_fixture.connect()
1748+ self.assertRaises(
1749+ swiftclient.ClientException,
1750+ swift_client.get_object, 'container', 'name')
1751+
1752
1753 class TestHashStream(TestCase):
1754 layer = BaseLayer
1755diff --git a/lib/lp/services/webapp/tests/test_servers.py b/lib/lp/services/webapp/tests/test_servers.py
1756index a5ffe0a..7b288fe 100644
1757--- a/lib/lp/services/webapp/tests/test_servers.py
1758+++ b/lib/lp/services/webapp/tests/test_servers.py
1759@@ -897,6 +897,14 @@ class TestWebServiceAccessTokens(TestCaseWithFactory):
1760 repository,
1761 ["repository:build_status", "repository:another_scope"])
1762
1763+ def test_checkRequest_contains_context(self):
1764+ [ref] = self.factory.makeGitRefs()
1765+ self._makeAccessTokenVerifiedRequest(
1766+ target=ref.repository,
1767+ scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
1768+ getUtility(IWebServiceConfiguration).checkRequest(
1769+ ref, ["repository:build_status", "repository:another_scope"])
1770+
1771 def test_checkRequest_bad_context(self):
1772 repository = self.factory.makeGitRepository()
1773 self._makeAccessTokenVerifiedRequest(
1774diff --git a/lib/lp/services/webservice/configuration.py b/lib/lp/services/webservice/configuration.py
1775index 8b56842..665c64e 100644
1776--- a/lib/lp/services/webservice/configuration.py
1777+++ b/lib/lp/services/webservice/configuration.py
1778@@ -15,8 +15,13 @@ from zope.security.interfaces import Unauthorized
1779 from lp.app import versioninfo
1780 from lp.services.config import config
1781 from lp.services.database.sqlbase import block_implicit_flushes
1782+from lp.services.webapp.canonicalurl import nearest_adapter
1783 from lp.services.webapp.interaction import get_interaction_extras
1784-from lp.services.webapp.interfaces import ILaunchBag
1785+from lp.services.webapp.interfaces import (
1786+ ILaunchBag,
1787+ ILaunchpadContainer,
1788+ )
1789+from lp.services.webapp.publisher import canonical_url
1790 from lp.services.webapp.servers import (
1791 WebServiceClientRequest,
1792 WebServicePublication,
1793@@ -102,9 +107,19 @@ class LaunchpadWebServiceConfiguration(BaseWebServiceConfiguration):
1794 access_token = get_interaction_extras().access_token
1795 if access_token is None:
1796 return
1797- if access_token.target != context:
1798- raise Unauthorized(
1799- "Current authentication does not allow access to this object.")
1800+
1801+ # The access token must be for a target that either exactly matches
1802+ # or contains the context object.
1803+ if access_token.target == context:
1804+ pass
1805+ else:
1806+ container = nearest_adapter(context, ILaunchpadContainer)
1807+ if not container.isWithin(
1808+ canonical_url(access_token.target, force_local_path=True)):
1809+ raise Unauthorized(
1810+ "Current authentication does not allow access to this "
1811+ "object.")
1812+
1813 if not required_scopes:
1814 raise Unauthorized(
1815 "Current authentication only allows calling scoped methods.")
1816diff --git a/lib/lp/soyuz/scripts/initialize_distroseries.py b/lib/lp/soyuz/scripts/initialize_distroseries.py
1817index 1ca534d..5a773bf 100644
1818--- a/lib/lp/soyuz/scripts/initialize_distroseries.py
1819+++ b/lib/lp/soyuz/scripts/initialize_distroseries.py
1820@@ -1,4 +1,4 @@
1821-# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
1822+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
1823 # GNU Affero General Public License version 3 (see the file LICENSE).
1824
1825 """Initialize a distroseries from its parent distroseries."""
1826@@ -383,6 +383,9 @@ class InitializeDistroSeries:
1827 self.distroseries.backports_not_automatic = any(
1828 parent.backports_not_automatic
1829 for parent in self.derivation_parents)
1830+ self.distroseries.proposed_not_automatic = any(
1831+ parent.proposed_not_automatic
1832+ for parent in self.derivation_parents)
1833 self.distroseries.include_long_descriptions = any(
1834 parent.include_long_descriptions
1835 for parent in self.derivation_parents)
1836diff --git a/lib/lp/soyuz/scripts/tests/test_initialize_distroseries.py b/lib/lp/soyuz/scripts/tests/test_initialize_distroseries.py
1837index 4079947..b3edb46 100644
1838--- a/lib/lp/soyuz/scripts/tests/test_initialize_distroseries.py
1839+++ b/lib/lp/soyuz/scripts/tests/test_initialize_distroseries.py
1840@@ -1,4 +1,4 @@
1841-# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
1842+# Copyright 2010-2021 Canonical Ltd. This software is licensed under the
1843 # GNU Affero General Public License version 3 (see the file LICENSE).
1844
1845 """Test the initialize_distroseries script machinery."""
1846@@ -93,6 +93,7 @@ class InitializationHelperTestCase(TestCaseWithFactory):
1847 if existing_format_selection is None:
1848 spfss_utility.add(parent, format_selection)
1849 parent.backports_not_automatic = True
1850+ parent.proposed_not_automatic = True
1851 parent.include_long_descriptions = False
1852 parent.index_compressors = [IndexCompressionType.XZ]
1853 parent.publish_by_hash = True
1854@@ -685,6 +686,7 @@ class TestInitializeDistroSeries(InitializationHelperTestCase):
1855 SourcePackageFormat.FORMAT_1_0))
1856 # Other configuration bits are copied too.
1857 self.assertTrue(child.backports_not_automatic)
1858+ self.assertTrue(child.proposed_not_automatic)
1859 self.assertFalse(child.include_long_descriptions)
1860 self.assertEqual([IndexCompressionType.XZ], child.index_compressors)
1861 self.assertTrue(child.publish_by_hash)
1862diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
1863index 9d59ba8..db48ed3 100644
1864--- a/lib/lp/testing/factory.py
1865+++ b/lib/lp/testing/factory.py
1866@@ -131,7 +131,10 @@ from lp.code.interfaces.gitref import (
1867 IGitRef,
1868 IGitRefRemoteSet,
1869 )
1870-from lp.code.interfaces.gitrepository import IGitRepository
1871+from lp.code.interfaces.gitrepository import (
1872+ IGitRepository,
1873+ IRevisionStatusArtifactSet,
1874+ )
1875 from lp.code.interfaces.linkedbranch import ICanHasLinkedBranch
1876 from lp.code.interfaces.revision import IRevisionSet
1877 from lp.code.interfaces.sourcepackagerecipe import (
1878@@ -146,6 +149,7 @@ from lp.code.model.diff import (
1879 Diff,
1880 PreviewDiff,
1881 )
1882+from lp.code.model.gitrepository import IRevisionStatusReportSet
1883 from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
1884 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
1885 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
1886@@ -1843,6 +1847,30 @@ class BareLaunchpadObjectFactory(ObjectFactory):
1887 grantee, grantor, can_create=can_create, can_push=can_push,
1888 can_force_push=can_force_push)
1889
1890+ def makeRevisionStatusReport(self, user=None, title=None,
1891+ git_repository=None, commit_sha1=None,
1892+ result_summary=None, url=None, result=None):
1893+ """Create a new RevisionStatusReport."""
1894+ if title is None:
1895+ title = self.getUniqueUnicode()
1896+ if git_repository is None:
1897+ git_repository = self.makeGitRepository()
1898+ if user is None:
1899+ user = git_repository.owner
1900+ if commit_sha1 is None:
1901+ commit_sha1 = hashlib.sha1(self.getUniqueBytes()).hexdigest()
1902+ return getUtility(IRevisionStatusReportSet).new(
1903+ user, title, git_repository, commit_sha1, result_summary,
1904+ url, result)
1905+
1906+ def makeRevisionStatusArtifact(self, lfa=None, report=None):
1907+ """Create a new RevisionStatusArtifact."""
1908+ if lfa is None:
1909+ lfa = self.makeLibraryFileAlias()
1910+ if report is None:
1911+ report = self.makeRevisionStatusReport()
1912+ return getUtility(IRevisionStatusArtifactSet).new(lfa, report)
1913+
1914 def makeBug(self, target=None, owner=None, bug_watch_url=None,
1915 information_type=None, date_closed=None, title=None,
1916 date_created=None, description=None, comment=None,
1917diff --git a/lib/lp/testing/swift/fakeswift.py b/lib/lp/testing/swift/fakeswift.py
1918index e3dd5e7..f0134d8 100644
1919--- a/lib/lp/testing/swift/fakeswift.py
1920+++ b/lib/lp/testing/swift/fakeswift.py
1921@@ -135,6 +135,8 @@ class FakeKeystone(resource.Resource):
1922 if tenant_name not in self.root.tenants:
1923 request.setResponseCode(http.FORBIDDEN)
1924 return b""
1925+ if username == "timeout":
1926+ time.sleep(60)
1927 if username not in self.users:
1928 request.setResponseCode(http.FORBIDDEN)
1929 return b""
1930diff --git a/lib/lp/translations/scripts/po_import.py b/lib/lp/translations/scripts/po_import.py
1931index 0cb9200..8b7d9b7 100644
1932--- a/lib/lp/translations/scripts/po_import.py
1933+++ b/lib/lp/translations/scripts/po_import.py
1934@@ -140,11 +140,8 @@ class TranslationsImport(LaunchpadCronScript):
1935 katie = getUtility(ILaunchpadCelebrities).katie
1936 if entry.importer == katie:
1937 # Email import state to Debian imports email.
1938- to_email = None
1939- else:
1940- to_email = get_contact_email_addresses(entry.importer)
1941-
1942- if to_email:
1943+ return
1944+ for to_email in get_contact_email_addresses(entry.importer):
1945 text = MailWrapper().format(mail_body)
1946 simple_sendmail(from_email, to_email, mail_subject, text)
1947
1948diff --git a/lib/lp/translations/scripts/tests/test_translations_import.py b/lib/lp/translations/scripts/tests/test_translations_import.py
1949index 07d8776..ffc61fd 100644
1950--- a/lib/lp/translations/scripts/tests/test_translations_import.py
1951+++ b/lib/lp/translations/scripts/tests/test_translations_import.py
1952@@ -188,6 +188,25 @@ class TestTranslationsImport(TestCaseWithFactory):
1953 self.assertEqual(
1954 [self.owner.preferredemail.email], self._getEmailRecipients())
1955
1956+ def test_notifies_uploader_separately(self):
1957+ """Do not put several addresses into one `to`-field"""
1958+ p1 = self.factory.makePerson()
1959+ p2 = self.factory.makePerson()
1960+ uploader = self.factory.makeTeam(
1961+ members=[p1, p2],
1962+ )
1963+ entry = self._makeApprovedEntry(uploader=uploader)
1964+ transaction.commit()
1965+ self.script._importEntry(entry)
1966+ transaction.commit()
1967+
1968+ # three emails get generated
1969+ # and each has only one recipient
1970+ self.assertEqual(3, len(stub.test_emails))
1971+ for email in stub.test_emails:
1972+ number_of_to_addresses = len(email[1])
1973+ self.assertEqual(1, number_of_to_addresses)
1974+
1975 def test_does_not_notify_vcs_imports(self):
1976 vcs_imports = getUtility(ILaunchpadCelebrities).vcs_imports
1977 entry = self._makeApprovedEntry(vcs_imports)

Subscribers

People subscribed via source and target branches

to status/vote changes: