Merge lp:~cjwatson/launchpad/process-accepted-bugs-job into lp:launchpad

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: no longer in the source branch.
Merged at revision: 15988
Proposed branch: lp:~cjwatson/launchpad/process-accepted-bugs-job
Merge into: lp:launchpad
Diff against target: 975 lines (+598/-119)
10 files modified
lib/lp/services/config/schema-lazr.conf (+7/-0)
lib/lp/services/features/flags.py (+6/-0)
lib/lp/soyuz/configure.zcml (+26/-14)
lib/lp/soyuz/interfaces/processacceptedbugsjob.py (+57/-0)
lib/lp/soyuz/model/processacceptedbugsjob.py (+174/-0)
lib/lp/soyuz/scripts/processaccepted.py (+47/-80)
lib/lp/soyuz/scripts/tests/test_processaccepted.py (+3/-3)
lib/lp/soyuz/scripts/tests/test_queue.py (+34/-6)
lib/lp/soyuz/tests/test_processaccepted.py (+17/-16)
lib/lp/soyuz/tests/test_processacceptedbugsjob.py (+227/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/process-accepted-bugs-job
Reviewer Review Type Date Requested Status
Steve Kowalik (community) code Approve
Review via email: mp+122420@code.launchpad.net

Commit message

When accepting packages, create a job to close bugs rather than closing them directly.

Description of the change

== Summary ==

Bug 745799: Accepting an upload may need to close an arbitrarily large number of bugs. Closing bugs involves a large number of queries due to structural subscriptions. Uploads are sometimes accepted in contexts involving a timeout (notably DistroSeries:+queue and the PackageUpload.acceptFromQueue API method). Even if structural subscription handling were made more efficient, the unbounded nature of the work means that there's a reasonable argument that it ought to be handled asynchronously in any event.

== Proposed fix ==

I understand that notifications may be made asynchronous at some point. I'm not exactly sure what shape that work will take as I haven't seen anything concrete. Pending that, I think a reasonable solution is simply to create a dedicated job for this, which can be run frequently; this can always be refactored in terms of something more general later if it turns out to be appropriate.

== Pre-implementation notes ==

  http://irclogs.ubuntu.com/2012/08/04/%23launchpad-dev.html#t01:49

Also, when this is deployed, we'll need to change the production crontabs to run this job. We could also pre-emptively change ackee-launchpad to run 'cronscripts/process-job-source-groups.py FREQUENT' rather than 'cronscripts/process-job-source.py IPlainPackageCopyJobSource', which would be my personal preference, but mthaddon had some reservations:

  https://irclogs.canonical.com/2012/08/01/%23launchpad-ops.html#t10:36

Comments welcome.

== LOC Rationale ==

+445. This is the last blocker for https://code.launchpad.net/~cjwatson/launchpad/remove-queue-tool/+merge/114464, which will be -1956 or thereabouts, and even given the queue API extensions that preceded this the whole arc comes out several hundred lines in the black.

== Tests ==

bin/test -vvct lp.soyuz.tests.test_processaccepted -t lp.soyuz.scripts.tests.test_queue.TestQueuePageClosingBugs

== Demo and Q/A ==

Set a distroseries to FROZEN on dogfood; upload a package to it, closing some open bug on that package; accept the package from the queue using 'queue accept' (lp:ubuntu-archive-tools) or the web UI; check that the bug is not closed immediately; run 'cronscripts/process-job-source.py IProcessAcceptedBugsJobSource'; check that the bug is now closed.

== Lint ==

Pre-existing lint, not straightforwardly fixable:

./lib/lp/services/config/schema-lazr.conf
     450: Line exceeds 80 characters.
    1032: Line exceeds 80 characters.
    1039: Line exceeds 80 characters.
    1577: Line exceeds 80 characters.

To post a comment you must log in.
Revision history for this message
Steve Kowalik (stevenk) wrote :

374 + bug_ids = []
375 for bug_id in bugs_fixed_line.split():
376 if not bug_id.isdigit():
377 continue
389 + bug_ids.append(int(bug_id))
390 + return bug_ids

Excuse the terrible indentation, but what about return [int(bug_id) for bug_id in bugs_fixed_line.split() if bug_id.isdigits()] instead of that block?

I'd also prefer to see the tests running under the process_accepted dbuser so we can pick up any missing permissions -- on say, public.job.

review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) wrote :

Thanks, I've applied something similar to your suggestion for get_bug_ids_from_changes_file.

The job tests do run under the process_accepted dbuser; they just spell it "config.IProcessAcceptedBugsJobSource.dbuser".

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/services/config/schema-lazr.conf'
2--- lib/lp/services/config/schema-lazr.conf 2012-08-09 04:44:13 +0000
3+++ lib/lp/services/config/schema-lazr.conf 2012-09-19 23:41:25 +0000
4@@ -1747,6 +1747,7 @@
5 IMembershipNotificationJobSource,
6 IPersonMergeJobSource,
7 IPlainPackageCopyJobSource,
8+ IProcessAcceptedBugsJobSource,
9 IQuestionEmailJobSource,
10 IRemoveArtifactSubscriptionsJobSource,
11 ISevenDayCommercialExpirationJobSource,
12@@ -1775,6 +1776,12 @@
13 dbuser: copy_packages
14 crontab_group: FREQUENT
15
16+[IProcessAcceptedBugsJobSource]
17+# This section is used by cronscripts/process-job-source.py.
18+module: lp.soyuz.interfaces.processacceptedbugsjob
19+dbuser: process_accepted
20+crontab_group: FREQUENT
21+
22 [IQuestionEmailJobSource]
23 # This section is used by cronscripts/process-job-source.py.
24 module: lp.answers.interfaces.questionjob
25
26=== modified file 'lib/lp/services/features/flags.py'
27--- lib/lp/services/features/flags.py 2012-08-31 14:35:33 +0000
28+++ lib/lp/services/features/flags.py 2012-09-19 23:41:25 +0000
29@@ -264,6 +264,12 @@
30 'disabled',
31 '',
32 'https://dev.launchpad.net/LEP/PrivateProjects'),
33+ ('soyuz.processacceptedbugsjob.enabled',
34+ 'boolean',
35+ 'If true, use a job to close bugs when accepting uploads from a queue.',
36+ 'disabled',
37+ '',
38+ ''),
39
40 ])
41
42
43=== modified file 'lib/lp/soyuz/configure.zcml'
44--- lib/lp/soyuz/configure.zcml 2012-07-11 13:27:35 +0000
45+++ lib/lp/soyuz/configure.zcml 2012-09-19 23:41:25 +0000
46@@ -988,19 +988,31 @@
47 <allow interface="lp.soyuz.interfaces.packagerelationship.IPackageRelationshipSet"/>
48 </class>
49
50- <!-- Overrides -->
51- <class class="lp.soyuz.adapters.overrides.SourceOverride">
52- <allow interface="lp.soyuz.adapters.overrides.ISourceOverride" />
53- </class>
54- <class class="lp.soyuz.adapters.overrides.BinaryOverride">
55- <allow interface="lp.soyuz.adapters.overrides.IBinaryOverride" />
56- </class>
57-
58- <!-- OverridePolicy -->
59- <class class="lp.soyuz.adapters.overrides.UbuntuOverridePolicy">
60- <allow interface="lp.soyuz.adapters.overrides.IOverridePolicy" />
61- </class>
62-
63- <webservice:register module="lp.soyuz.interfaces.webservice" />
64+ <!-- Overrides -->
65+ <class class="lp.soyuz.adapters.overrides.SourceOverride">
66+ <allow interface="lp.soyuz.adapters.overrides.ISourceOverride" />
67+ </class>
68+ <class class="lp.soyuz.adapters.overrides.BinaryOverride">
69+ <allow interface="lp.soyuz.adapters.overrides.IBinaryOverride" />
70+ </class>
71+
72+ <!-- OverridePolicy -->
73+ <class class="lp.soyuz.adapters.overrides.UbuntuOverridePolicy">
74+ <allow interface="lp.soyuz.adapters.overrides.IOverridePolicy" />
75+ </class>
76+
77+ <!-- ProcessAcceptedBugsJobSource -->
78+ <securedutility
79+ component=".model.processacceptedbugsjob.ProcessAcceptedBugsJob"
80+ provides=".interfaces.processacceptedbugsjob.IProcessAcceptedBugsJobSource">
81+ <allow interface=".interfaces.processacceptedbugsjob.IProcessAcceptedBugsJobSource" />
82+ </securedutility>
83+
84+ <!-- ProcessAcceptedBugsJob -->
85+ <class class=".model.processacceptedbugsjob.ProcessAcceptedBugsJob">
86+ <allow interface=".interfaces.processacceptedbugsjob.IProcessAcceptedBugsJob" />
87+ </class>
88+
89+ <webservice:register module="lp.soyuz.interfaces.webservice" />
90
91 </configure>
92
93=== added file 'lib/lp/soyuz/interfaces/processacceptedbugsjob.py'
94--- lib/lp/soyuz/interfaces/processacceptedbugsjob.py 1970-01-01 00:00:00 +0000
95+++ lib/lp/soyuz/interfaces/processacceptedbugsjob.py 2012-09-19 23:41:25 +0000
96@@ -0,0 +1,57 @@
97+# Copyright 2012 Canonical Ltd. This software is licensed under the
98+# GNU Affero General Public License version 3 (see the file LICENSE).
99+
100+__metaclass__ = type
101+
102+__all__ = [
103+ "IProcessAcceptedBugsJob",
104+ "IProcessAcceptedBugsJobSource",
105+ ]
106+
107+from lazr.restful.fields import Reference
108+from zope.interface import Attribute
109+
110+from lp import _
111+from lp.registry.interfaces.distroseries import IDistroSeries
112+from lp.services.job.interfaces.job import (
113+ IJob,
114+ IJobSource,
115+ IRunnableJob,
116+ )
117+from lp.soyuz.interfaces.sourcepackagerelease import ISourcePackageRelease
118+
119+
120+class IProcessAcceptedBugsJob(IRunnableJob):
121+ """An `IJob` to close bugs for accepted package uploads."""
122+
123+ job = Reference(
124+ schema=IJob, title=_("The common Job attributes"),
125+ required=True, readonly=True)
126+
127+ distroseries = Reference(
128+ schema=IDistroSeries, title=_("Context distroseries"),
129+ required=True, readonly=True)
130+
131+ sourcepackagerelease = Reference(
132+ schema=ISourcePackageRelease, title=_("Context sourcepackagerelease"),
133+ required=True, readonly=True)
134+
135+ metadata = Attribute(_("A dict of data about the job."))
136+
137+ bug_ids = Attribute(_("A list of bug IDs."))
138+
139+ def getOperationDescription():
140+ """Return a description of the bug-closing operation."""
141+
142+
143+class IProcessAcceptedBugsJobSource(IJobSource):
144+ """A source for jobs to close bugs for accepted package uploads."""
145+
146+ def create(distroseries, sourcepackagerelease, bug_ids):
147+ """Create a new `IProcessAcceptedBugsJob`.
148+
149+ :param distroseries: A `IDistroSeries` for which to close bugs.
150+ :param sourcepackagerelease: An `ISourcePackageRelease` for which to
151+ close bugs.
152+ :param bug_ids: An iterable of bug IDs to close.
153+ """
154
155=== added file 'lib/lp/soyuz/model/processacceptedbugsjob.py'
156--- lib/lp/soyuz/model/processacceptedbugsjob.py 1970-01-01 00:00:00 +0000
157+++ lib/lp/soyuz/model/processacceptedbugsjob.py 2012-09-19 23:41:25 +0000
158@@ -0,0 +1,174 @@
159+# Copyright 2012 Canonical Ltd. This software is licensed under the
160+# GNU Affero General Public License version 3 (see the file LICENSE).
161+
162+__metaclass__ = type
163+
164+__all__ = [
165+ "close_bug_ids_for_sourcepackagerelease",
166+ "ProcessAcceptedBugsJob",
167+ ]
168+
169+import logging
170+
171+from storm.locals import (
172+ And,
173+ Int,
174+ JSON,
175+ Reference,
176+ )
177+from zope.component import getUtility
178+from zope.interface import (
179+ classProvides,
180+ implements,
181+ )
182+from zope.security.proxy import removeSecurityProxy
183+
184+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
185+from lp.bugs.interfaces.bug import IBugSet
186+from lp.bugs.interfaces.bugtask import BugTaskStatus
187+from lp.registry.model.distroseries import DistroSeries
188+from lp.services.config import config
189+from lp.services.database.lpstorm import IMasterStore
190+from lp.services.database.stormbase import StormBase
191+from lp.services.job.model.job import Job
192+from lp.services.job.runner import BaseRunnableJob
193+from lp.services.webapp.interfaces import (
194+ DEFAULT_FLAVOR,
195+ IStoreSelector,
196+ MAIN_STORE,
197+ )
198+from lp.soyuz.interfaces.processacceptedbugsjob import (
199+ IProcessAcceptedBugsJob,
200+ IProcessAcceptedBugsJobSource,
201+ )
202+from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
203+
204+
205+def close_bug_ids_for_sourcepackagerelease(distroseries, spr, bug_ids):
206+ bugs = list(getUtility(IBugSet).getByNumbers(bug_ids))
207+ janitor = getUtility(ILaunchpadCelebrities).janitor
208+ target = distroseries.getSourcePackage(spr.sourcepackagename)
209+ assert spr.changelog_entry is not None, (
210+ "New source uploads should have a changelog.")
211+ content = (
212+ "This bug was fixed in the package %s"
213+ "\n\n---------------\n%s" % (spr.title, spr.changelog_entry))
214+
215+ for bug in bugs:
216+ # We need to remove the security proxy here because the bug might be
217+ # private and if this code is called via someone using the +queue
218+ # page they will get an OOPS. BE CAREFUL with the unproxied bug
219+ # object and look at what you're doing with it that might violate
220+ # security.
221+ # XXX cjwatson 2012-09-19: This can be removed once the
222+ # soyuz.processacceptedbugsjob.enabled feature flag is switched on
223+ # everywhere and the code to support disabling it is removed.
224+ bug = removeSecurityProxy(bug)
225+ edited_task = bug.setStatus(
226+ target=target, status=BugTaskStatus.FIXRELEASED, user=janitor)
227+ if edited_task is not None:
228+ bug.newMessage(
229+ owner=janitor,
230+ subject=bug.followup_subject(),
231+ content=content)
232+
233+
234+class ProcessAcceptedBugsJob(StormBase, BaseRunnableJob):
235+ """Base class for jobs to close bugs for accepted package uploads."""
236+
237+ __storm_table__ = "ProcessAcceptedBugsJob"
238+
239+ config = config.IProcessAcceptedBugsJobSource
240+
241+ implements(IProcessAcceptedBugsJob)
242+
243+ # Oddly, BaseRunnableJob inherits from BaseRunnableJobSource so this class
244+ # is both the factory for jobs (the "implements", above) and the source
245+ # for runnable jobs (not the constructor of the job source, the class
246+ # provides the IJobSource interface itself).
247+ classProvides(IProcessAcceptedBugsJobSource)
248+
249+ # The Job table contains core job details.
250+ job_id = Int("job", primary=True)
251+ job = Reference(job_id, Job.id)
252+
253+ distroseries_id = Int(name="distroseries")
254+ distroseries = Reference(distroseries_id, DistroSeries.id)
255+
256+ sourcepackagerelease_id = Int(name="sourcepackagerelease")
257+ sourcepackagerelease = Reference(
258+ sourcepackagerelease_id, SourcePackageRelease.id)
259+
260+ metadata = JSON('json_data')
261+
262+ def __init__(self, distroseries, sourcepackagerelease, bug_ids):
263+ self.job = Job()
264+ self.distroseries = distroseries
265+ self.sourcepackagerelease = sourcepackagerelease
266+ self.metadata = {"bug_ids": list(bug_ids)}
267+ super(ProcessAcceptedBugsJob, self).__init__()
268+
269+ @property
270+ def bug_ids(self):
271+ return self.metadata["bug_ids"]
272+
273+ @classmethod
274+ def create(cls, distroseries, sourcepackagerelease, bug_ids):
275+ """See `IProcessAcceptedBugsJobSource`."""
276+ assert distroseries is not None, "No distroseries specified."
277+ assert sourcepackagerelease is not None, (
278+ "No sourcepackagerelease specified.")
279+ assert sourcepackagerelease.changelog_entry is not None, (
280+ "New source uploads should have a changelog.")
281+ assert bug_ids, "No bug IDs specified."
282+ job = ProcessAcceptedBugsJob(
283+ distroseries, sourcepackagerelease, bug_ids)
284+ IMasterStore(ProcessAcceptedBugsJob).add(job)
285+ job.celeryRunOnCommit()
286+ return job
287+
288+ def getOperationDescription(self):
289+ """See `IRunnableJob`."""
290+ return "closing bugs for accepted package upload"
291+
292+ def run(self):
293+ """See `IRunnableJob`."""
294+ logger = logging.getLogger()
295+ spr = self.sourcepackagerelease
296+ logger.info(
297+ "Closing bugs for %s/%s (%s)" %
298+ (spr.name, spr.version, self.distroseries))
299+ close_bug_ids_for_sourcepackagerelease(
300+ self.distroseries, spr, self.metadata["bug_ids"])
301+
302+ def __repr__(self):
303+ """Returns an informative representation of the job."""
304+ parts = ["%s to close bugs [" % self.__class__.__name__]
305+ parts.append(", ".join(str(bug_id) for bug_id in self.bug_ids))
306+ spr = self.sourcepackagerelease
307+ parts.append(
308+ "] for %s/%s (%s)" % (spr.name, spr.version, self.distroseries))
309+ return "<%s>" % "".join(parts)
310+
311+ @staticmethod
312+ def iterReady():
313+ """See `IJobSource`."""
314+ store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
315+ return store.find((ProcessAcceptedBugsJob),
316+ And(ProcessAcceptedBugsJob.job == Job.id,
317+ Job.id.is_in(Job.ready_jobs)))
318+
319+ def makeDerived(self):
320+ """Support UniversalJobSource.
321+
322+ (Most Job ORM classes are generic, because their database table is
323+ used for several related job types. Therefore, they have derived
324+ classes to implement the specific Job.
325+
326+ ProcessAcceptedBugsJob implements the specific job, so its
327+ makeDerived returns itself.)
328+ """
329+ return self
330+
331+ def getDBClass(self):
332+ return self.__class__
333
334=== modified file 'lib/lp/soyuz/scripts/processaccepted.py'
335--- lib/lp/soyuz/scripts/processaccepted.py 2012-07-05 09:43:58 +0000
336+++ lib/lp/soyuz/scripts/processaccepted.py 2012-09-19 23:41:25 +0000
337@@ -1,4 +1,4 @@
338-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
339+# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
340 # GNU Affero General Public License version 3 (see the file LICENSE).
341
342 """Helper functions for the process-accepted.py script."""
343@@ -8,7 +8,7 @@
344 'close_bugs_for_queue_item',
345 'close_bugs_for_sourcepackagerelease',
346 'close_bugs_for_sourcepublication',
347- 'get_bugs_from_changes_file',
348+ 'get_bug_ids_from_changes_file',
349 'ProcessAccepted',
350 ]
351
352@@ -17,20 +17,18 @@
353
354 from debian.deb822 import Deb822Dict
355 from zope.component import getUtility
356-from zope.security.proxy import removeSecurityProxy
357+from zope.security.management import getSecurityPolicy
358
359-from lp.app.errors import NotFoundError
360-from lp.app.interfaces.launchpad import ILaunchpadCelebrities
361 from lp.archivepublisher.publishing import GLOBAL_PUBLISHER_LOCK
362 from lp.archiveuploader.tagfiles import parse_tagfile_content
363-from lp.bugs.interfaces.bug import IBugSet
364-from lp.bugs.interfaces.bugtask import BugTaskStatus
365 from lp.registry.interfaces.distribution import IDistributionSet
366 from lp.registry.interfaces.pocket import PackagePublishingPocket
367+from lp.services.features import getFeatureFlag
368 from lp.services.scripts.base import (
369 LaunchpadCronScript,
370 LaunchpadScriptFailure,
371 )
372+from lp.services.webapp.authorization import LaunchpadPermissiveSecurityPolicy
373 from lp.services.webapp.errorlog import (
374 ErrorReportingUtility,
375 ScriptRequest,
376@@ -43,34 +41,29 @@
377 re_lp_closes,
378 )
379 from lp.soyuz.interfaces.archive import IArchiveSet
380+from lp.soyuz.interfaces.processacceptedbugsjob import (
381+ IProcessAcceptedBugsJobSource,
382+ )
383 from lp.soyuz.interfaces.queue import IPackageUploadSet
384-
385-
386-def get_bugs_from_changes_file(changes_file):
387- """Parse the changes file and return a list of bugs referenced by it.
388+from lp.soyuz.model.processacceptedbugsjob import (
389+ close_bug_ids_for_sourcepackagerelease,
390+ )
391+
392+
393+def get_bug_ids_from_changes_file(changes_file):
394+ """Parse the changes file and return a list of bug IDs referenced by it.
395
396 The bugs is specified in the Launchpad-bugs-fixed header, and are
397 separated by a space character. Nonexistent bug ids are ignored.
398 """
399 tags = Deb822Dict(parse_tagfile_content(changes_file.read()))
400- bugs_fixed_line = tags.get('Launchpad-bugs-fixed', '')
401- bugs = []
402- for bug_id in bugs_fixed_line.split():
403- if not bug_id.isdigit():
404- continue
405- bug_id = int(bug_id)
406- try:
407- bug = getUtility(IBugSet).get(bug_id)
408- except NotFoundError:
409- continue
410- else:
411- bugs.append(bug)
412- return bugs
413-
414-
415-def get_bugs_from_changelog_entry(sourcepackagerelease, since_version):
416+ bugs_fixed = tags.get('Launchpad-bugs-fixed', '').split()
417+ return [int(bug_id) for bug_id in bugs_fixed if bug_id.isdigit()]
418+
419+
420+def get_bug_ids_from_changelog_entry(sourcepackagerelease, since_version):
421 """Parse the changelog_entry in the sourcepackagerelease and return a
422- list of `IBug`s referenced by it.
423+ list of bug IDs referenced by it.
424 """
425 changelog = sourcepackagerelease.aggregate_changelog(since_version)
426 closes = []
427@@ -84,17 +77,7 @@
428 for match in regex:
429 bug_match = re_bug_numbers.findall(match.group(0))
430 closes += map(int, bug_match)
431-
432- bugs = []
433- for bug_id in closes:
434- try:
435- bug = getUtility(IBugSet).get(bug_id)
436- except NotFoundError:
437- continue
438- else:
439- bugs.append(bug)
440-
441- return bugs
442+ return closes
443
444
445 def can_close_bugs(target):
446@@ -148,7 +131,8 @@
447
448 for source_queue_item in queue_item.sources:
449 close_bugs_for_sourcepackagerelease(
450- source_queue_item.sourcepackagerelease, changesfile_object)
451+ queue_item.distroseries, source_queue_item.sourcepackagerelease,
452+ changesfile_object)
453
454
455 def close_bugs_for_sourcepublication(source_publication, since_version=None):
456@@ -164,17 +148,18 @@
457 changesfile_object = sourcepackagerelease.upload_changesfile
458
459 close_bugs_for_sourcepackagerelease(
460- sourcepackagerelease, changesfile_object, since_version,
461- upload_distroseries=source_publication.distroseries)
462-
463-
464-def close_bugs_for_sourcepackagerelease(source_release, changesfile_object,
465- since_version=None,
466- upload_distroseries=None):
467+ source_publication.distroseries, sourcepackagerelease,
468+ changesfile_object, since_version)
469+
470+
471+def close_bugs_for_sourcepackagerelease(distroseries, source_release,
472+ changesfile_object,
473+ since_version=None):
474 """Close bugs for a given source.
475
476- Given a `ISourcePackageRelease` and a corresponding changesfile object,
477- close bugs mentioned in the changesfile in the context of the source.
478+ Given an `IDistroSeries`, an `ISourcePackageRelease`, and a
479+ corresponding changesfile object, close bugs mentioned in the
480+ changesfile in the context of the source.
481
482 If changesfile_object is None and since_version is supplied,
483 close all the bugs in changelog entries made after that version and up
484@@ -184,45 +169,27 @@
485 requirement to do so right now.
486 """
487 if since_version and source_release.changelog:
488- bugs_to_close = get_bugs_from_changelog_entry(
489+ bug_ids_to_close = get_bug_ids_from_changelog_entry(
490 source_release, since_version=since_version)
491 elif changesfile_object:
492- bugs_to_close = get_bugs_from_changes_file(changesfile_object)
493+ bug_ids_to_close = get_bug_ids_from_changes_file(changesfile_object)
494 else:
495 return
496
497 # No bugs to be closed by this upload, move on.
498- if not bugs_to_close:
499+ if not bug_ids_to_close:
500 return
501
502- janitor = getUtility(ILaunchpadCelebrities).janitor
503- for bug in bugs_to_close:
504- # We need to remove the security proxy here because the bug
505- # might be private and if this code is called via someone using
506- # the +queue page they will get an OOPS. Ideally, we should
507- # migrate this code to the Job system though, but that's a lot
508- # of work. If you don't do that and you're changing stuff in
509- # here, BE CAREFUL with the unproxied bug object and look at
510- # what you're doing with it that might violate security.
511- bug = removeSecurityProxy(bug)
512- if upload_distroseries is not None:
513- target = upload_distroseries.getSourcePackage(
514- source_release.sourcepackagename)
515- else:
516- target = source_release.sourcepackage
517- edited_task = bug.setStatus(
518- target=target, status=BugTaskStatus.FIXRELEASED, user=janitor)
519- if edited_task is not None:
520- assert source_release.changelog_entry is not None, (
521- "New source uploads should have a changelog.")
522- content = (
523- "This bug was fixed in the package %s"
524- "\n\n---------------\n%s" % (
525- source_release.title, source_release.changelog_entry))
526- bug.newMessage(
527- owner=janitor,
528- subject=bug.followup_subject(),
529- content=content)
530+ if (getSecurityPolicy() == LaunchpadPermissiveSecurityPolicy or
531+ not getFeatureFlag("soyuz.processacceptedbugsjob.enabled")):
532+ # We're already running in a script (or the feature flag to allow
533+ # use of the job is disabled), so we can just close the bugs
534+ # directly.
535+ close_bug_ids_for_sourcepackagerelease(
536+ distroseries, source_release, bug_ids_to_close)
537+ else:
538+ job_source = getUtility(IProcessAcceptedBugsJobSource)
539+ job_source.create(distroseries, source_release, bug_ids_to_close)
540
541
542 class TargetPolicy:
543
544=== modified file 'lib/lp/soyuz/scripts/tests/test_processaccepted.py'
545--- lib/lp/soyuz/scripts/tests/test_processaccepted.py 2012-01-01 02:58:52 +0000
546+++ lib/lp/soyuz/scripts/tests/test_processaccepted.py 2012-09-19 23:41:25 +0000
547@@ -1,4 +1,4 @@
548-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
549+# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
550 # GNU Affero General Public License version 3 (see the file LICENSE).
551
552 __metaclass__ = type
553@@ -94,8 +94,8 @@
554 bugs = self.makeChangelogWithBugs(spr)
555
556 # Call the method and test it's closed the bugs.
557- close_bugs_for_sourcepackagerelease(spr, changesfile_object=None,
558- since_version="1.0-1")
559+ close_bugs_for_sourcepackagerelease(
560+ spr.upload_distroseries, spr, None, since_version="1.0-1")
561 for bug, bugtask in bugs:
562 if bug.id != bugs[5][0].id:
563 self.assertEqual(BugTaskStatus.FIXRELEASED, bugtask.status)
564
565=== modified file 'lib/lp/soyuz/scripts/tests/test_queue.py'
566--- lib/lp/soyuz/scripts/tests/test_queue.py 2012-08-08 11:48:29 +0000
567+++ lib/lp/soyuz/scripts/tests/test_queue.py 2012-09-19 23:41:25 +0000
568@@ -1,4 +1,4 @@
569-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
570+# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
571 # GNU Affero General Public License version 3 (see the file LICENSE).
572
573 """queue tool base class tests."""
574@@ -35,6 +35,7 @@
575 from lp.registry.interfaces.series import SeriesStatus
576 from lp.services.config import config
577 from lp.services.database.lpstorm import IStore
578+from lp.services.features.testing import FeatureFixture
579 from lp.services.librarian.interfaces import ILibraryFileAliasSet
580 from lp.services.librarian.model import LibraryFileAlias
581 from lp.services.librarian.utils import filechunks
582@@ -47,6 +48,9 @@
583 PackageUploadStatus,
584 )
585 from lp.soyuz.interfaces.archive import IArchiveSet
586+from lp.soyuz.interfaces.processacceptedbugsjob import (
587+ IProcessAcceptedBugsJobSource,
588+ )
589 from lp.soyuz.interfaces.queue import IPackageUploadSet
590 from lp.soyuz.model.queue import PackageUploadBuild
591 from lp.soyuz.scripts.processaccepted import (
592@@ -1076,6 +1080,11 @@
593
594 layer = DatabaseFunctionalLayer
595
596+ def assertBugChanges(self, series, spr, bug):
597+ with celebrity_logged_in("admin"):
598+ self.assertEqual(
599+ BugTaskStatus.FIXRELEASED, bug.default_bugtask.status)
600+
601 def test_close_bugs_for_sourcepackagerelease_with_private_bug(self):
602 # lp.soyuz.scripts.processaccepted.close_bugs_for_sourcepackagerelease
603 # should work with private bugs where the person using the queue
604@@ -1085,8 +1094,8 @@
605 # we're testing.
606 spr = self.factory.makeSourcePackageRelease(changelog_entry="blah")
607 archive_admin = self.factory.makePerson()
608- dsp = spr.upload_distroseries.distribution.getSourcePackage(
609- spr.sourcepackagename)
610+ series = spr.upload_distroseries
611+ dsp = series.distribution.getSourcePackage(spr.sourcepackagename)
612 bug = self.factory.makeBug(
613 target=dsp, information_type=InformationType.USERDATA)
614 changes = StringIO(changes_file_template % bug.id)
615@@ -1095,12 +1104,31 @@
616 # The archive admin user can't normally see this bug.
617 self.assertRaises(ForbiddenAttribute, bug, 'status')
618 # But the bug closure should work.
619- close_bugs_for_sourcepackagerelease(spr, changes)
620+ close_bugs_for_sourcepackagerelease(series, spr, changes)
621
622 # Verify it was closed.
623+ self.assertBugChanges(series, spr, bug)
624+
625+
626+class TestQueuePageClosingBugsJob(TestQueuePageClosingBugs):
627+ # Repeat TestQueuePageClosingBugs, but with the feature flag set to
628+ # cause close_bugs_for_sourcepackagerelease to create a job rather than
629+ # closing bugs immediately.
630+
631+ def setUp(self):
632+ super(TestQueuePageClosingBugsJob, self).setUp()
633+ self.useFixture(FeatureFixture(
634+ {"soyuz.processacceptedbugsjob.enabled": "on"},
635+ ))
636+
637+ def assertBugChanges(self, series, spr, bug):
638 with celebrity_logged_in("admin"):
639- self.assertEqual(
640- bug.default_bugtask.status, BugTaskStatus.FIXRELEASED)
641+ self.assertEqual(BugTaskStatus.NEW, bug.default_bugtask.status)
642+ job_source = getUtility(IProcessAcceptedBugsJobSource)
643+ [job] = list(job_source.iterReady())
644+ self.assertEqual(series, job.distroseries)
645+ self.assertEqual(spr, job.sourcepackagerelease)
646+ self.assertEqual([bug.id], job.bug_ids)
647
648
649 class TestQueueToolInJail(TestQueueBase, TestCase):
650
651=== modified file 'lib/lp/soyuz/tests/test_processaccepted.py'
652--- lib/lp/soyuz/tests/test_processaccepted.py 2012-01-20 15:42:44 +0000
653+++ lib/lp/soyuz/tests/test_processaccepted.py 2012-09-19 23:41:25 +0000
654@@ -1,4 +1,4 @@
655-# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
656+# Copyright 2010-2012 Canonical Ltd. This software is licensed under the
657 # GNU Affero General Public License version 3 (see the file LICENSE).
658
659 """Test process-accepted.py"""
660@@ -22,7 +22,7 @@
661 )
662 from lp.soyuz.model.queue import PackageUpload
663 from lp.soyuz.scripts.processaccepted import (
664- get_bugs_from_changes_file,
665+ get_bug_ids_from_changes_file,
666 ProcessAccepted,
667 )
668 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
669@@ -221,54 +221,55 @@
670 dsp.derived_series.distribution, script.findTargetDistros())
671
672
673-class TestBugsFromChangesFile(TestCaseWithFactory):
674- """Test get_bugs_from_changes_file."""
675+class TestBugIDsFromChangesFile(TestCaseWithFactory):
676+ """Test get_bug_ids_from_changes_file."""
677
678 layer = LaunchpadZopelessLayer
679 dbuser = config.uploadqueue.dbuser
680
681 def setUp(self):
682- super(TestBugsFromChangesFile, self).setUp()
683+ super(TestBugIDsFromChangesFile, self).setUp()
684 self.changes = Changes({
685 'Format': '1.8',
686 'Source': 'swat',
687 })
688
689- def getBugs(self):
690- """Serialize self.changes and use get_bugs_from_changes_file to
691- extract bugs from it.
692+ def getBugIDs(self):
693+ """Serialize self.changes and use get_bug_ids_from_changes_file to
694+ extract bug IDs from it.
695 """
696 stream = StringIO()
697 self.changes.dump(stream)
698 stream.seek(0)
699- return get_bugs_from_changes_file(stream)
700+ return get_bug_ids_from_changes_file(stream)
701
702 def test_no_bugs(self):
703 # An empty list is returned if there are no bugs
704 # mentioned.
705- self.assertEquals([], self.getBugs())
706+ self.assertEqual([], self.getBugIDs())
707
708 def test_invalid_bug_id(self):
709 # Invalid bug ids (i.e. containing non-digit characters) are ignored.
710 self.changes["Launchpad-Bugs-Fixed"] = "bla"
711- self.assertEquals([], self.getBugs())
712+ self.assertEqual([], self.getBugIDs())
713
714 def test_unknown_bug_id(self):
715- # Unknown bug ids are ignored.
716+ # Unknown bug ids are passed through; they will be ignored later, by
717+ # close_bug_ids_for_sourcepackagerelease.
718 self.changes["Launchpad-Bugs-Fixed"] = "45120"
719- self.assertEquals([], self.getBugs())
720+ self.assertEqual([45120], self.getBugIDs())
721
722 def test_valid_bug(self):
723 # For valid bug ids the bug object is returned.
724 bug = self.factory.makeBug()
725 self.changes["Launchpad-Bugs-Fixed"] = "%d" % bug.id
726- self.assertEquals([bug], self.getBugs())
727+ self.assertEqual([bug.id], self.getBugIDs())
728
729 def test_case_sensitivity(self):
730 # The spelling of Launchpad-Bugs-Fixed is case-insensitive.
731 bug = self.factory.makeBug()
732 self.changes["LaUnchpad-Bugs-fixed"] = "%d" % bug.id
733- self.assertEquals([bug], self.getBugs())
734+ self.assertEqual([bug.id], self.getBugIDs())
735
736 def test_multiple_bugs(self):
737 # Multiple bug ids can be specified, separated by spaces.
738@@ -276,4 +277,4 @@
739 bug2 = self.factory.makeBug()
740 self.changes["Launchpad-Bugs-Fixed"] = "%d invalid %d" % (
741 bug1.id, bug2.id)
742- self.assertEquals([bug1, bug2], self.getBugs())
743+ self.assertEqual([bug1.id, bug2.id], self.getBugIDs())
744
745=== added file 'lib/lp/soyuz/tests/test_processacceptedbugsjob.py'
746--- lib/lp/soyuz/tests/test_processacceptedbugsjob.py 1970-01-01 00:00:00 +0000
747+++ lib/lp/soyuz/tests/test_processacceptedbugsjob.py 2012-09-19 23:41:25 +0000
748@@ -0,0 +1,227 @@
749+# Copyright 2012 Canonical Ltd. This software is licensed under the
750+# GNU Affero General Public License version 3 (see the file LICENSE).
751+
752+"""Tests for jobs to close bugs for accepted package uploads."""
753+
754+from itertools import product
755+
756+from testtools.content import text_content
757+import transaction
758+from zope.component import getUtility
759+from zope.security.proxy import removeSecurityProxy
760+
761+from lp.bugs.interfaces.bugtask import BugTaskStatus
762+from lp.registry.enums import InformationType
763+from lp.registry.interfaces.series import SeriesStatus
764+from lp.services.config import config
765+from lp.services.features.testing import FeatureFixture
766+from lp.services.job.interfaces.job import JobStatus
767+from lp.services.job.runner import JobRunner
768+from lp.services.job.tests import block_on_job
769+from lp.services.webapp.testing import verifyObject
770+from lp.soyuz.interfaces.processacceptedbugsjob import (
771+ IProcessAcceptedBugsJob,
772+ IProcessAcceptedBugsJobSource,
773+ )
774+from lp.soyuz.model.processacceptedbugsjob import (
775+ close_bug_ids_for_sourcepackagerelease,
776+ )
777+from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
778+from lp.testing import (
779+ run_script,
780+ TestCaseWithFactory,
781+ )
782+from lp.testing.fakemethod import FakeMethod
783+from lp.testing.layers import (
784+ CeleryJobLayer,
785+ LaunchpadZopelessLayer,
786+ )
787+
788+
789+class TestCloseBugIDsForSourcePackageRelease(TestCaseWithFactory):
790+
791+ layer = LaunchpadZopelessLayer
792+ dbuser = config.IProcessAcceptedBugsJobSource.dbuser
793+
794+ def setUp(self):
795+ super(TestCloseBugIDsForSourcePackageRelease, self).setUp()
796+ # Create a distribution with two series, two source package names,
797+ # and an SPR and a bug task for all combinations of those.
798+ self.distro = self.factory.makeDistribution()
799+ self.series = [
800+ self.factory.makeDistroSeries(
801+ distribution=self.distro, status=status)
802+ for status in (SeriesStatus.CURRENT, SeriesStatus.DEVELOPMENT)]
803+ self.spns = [self.factory.makeSourcePackageName() for _ in range(2)]
804+ self.bug = self.factory.makeBug()
805+ self.sprs = [
806+ self.factory.makeSourcePackageRelease(
807+ sourcepackagename=spn, distroseries=series,
808+ changelog_entry="changelog")
809+ for spn, series in product(self.spns, self.series)]
810+ self.bugtasks = [
811+ self.factory.makeBugTask(
812+ target=spr.upload_distroseries.getSourcePackage(
813+ spr.sourcepackagename),
814+ bug=self.bug)
815+ for spr in self.sprs]
816+
817+ def test_correct_tasks_with_distroseries(self):
818+ # Close the task for the correct source package name and the given
819+ # series.
820+ close_bug_ids_for_sourcepackagerelease(
821+ self.series[0], self.sprs[0], [self.bug.id])
822+ self.assertEqual(BugTaskStatus.FIXRELEASED, self.bugtasks[0].status)
823+ for i in (1, 2, 3):
824+ self.assertEqual(BugTaskStatus.NEW, self.bugtasks[i].status)
825+
826+ def test_correct_message(self):
827+ # When closing a bug, a reasonable message is added.
828+ close_bug_ids_for_sourcepackagerelease(
829+ self.series[0], self.sprs[0], [self.bug.id])
830+ self.assertEqual(2, self.bug.messages.count())
831+ self.assertEqual(
832+ "This bug was fixed in the package %s"
833+ "\n\n---------------\nchangelog" % self.sprs[0].title,
834+ self.bug.messages[1].text_contents)
835+
836+ def test_ignore_unknown_bug_ids(self):
837+ # Unknown bug IDs are ignored, and no message is added.
838+ close_bug_ids_for_sourcepackagerelease(
839+ self.series[0], self.sprs[0], [self.bug.id + 1])
840+ for bugtask in self.bugtasks:
841+ self.assertEqual(BugTaskStatus.NEW, bugtask.status)
842+ self.assertEqual(1, self.bug.messages.count())
843+
844+ def test_private_bug(self):
845+ # Closing private bugs is not a problem.
846+ self.bug.transitionToInformationType(
847+ InformationType.USERDATA, self.distro.owner)
848+ close_bug_ids_for_sourcepackagerelease(
849+ self.series[0], self.sprs[0], [self.bug.id])
850+ self.assertEqual(BugTaskStatus.FIXRELEASED, self.bugtasks[0].status)
851+
852+
853+class TestProcessAcceptedBugsJob(TestCaseWithFactory):
854+
855+ layer = LaunchpadZopelessLayer
856+ dbuser = config.IProcessAcceptedBugsJobSource.dbuser
857+
858+ def setUp(self):
859+ super(TestProcessAcceptedBugsJob, self).setUp()
860+ self.publisher = SoyuzTestPublisher()
861+ self.publisher.prepareBreezyAutotest()
862+ self.distroseries = self.publisher.breezy_autotest
863+
864+ def makeJob(self, distroseries=None, spr=None, bug_ids=[1]):
865+ """Create a `ProcessAcceptedBugsJob`."""
866+ if distroseries is None:
867+ distroseries = self.distroseries
868+ if spr is None:
869+ spr = self.factory.makeSourcePackageRelease(
870+ distroseries=distroseries, changelog_entry="changelog")
871+ return getUtility(IProcessAcceptedBugsJobSource).create(
872+ distroseries, spr, bug_ids)
873+
874+ def test_job_implements_IProcessAcceptedBugsJob(self):
875+ job = self.makeJob()
876+ self.assertTrue(verifyObject(IProcessAcceptedBugsJob, job))
877+
878+ def test_job_source_implements_IProcessAcceptedBugsJobSource(self):
879+ job_source = getUtility(IProcessAcceptedBugsJobSource)
880+ self.assertTrue(
881+ verifyObject(IProcessAcceptedBugsJobSource, job_source))
882+
883+ def test_create(self):
884+ # A ProcessAcceptedBugsJob can be created and stores its arguments.
885+ spr = self.factory.makeSourcePackageRelease(
886+ distroseries=self.distroseries, changelog_entry="changelog")
887+ bug_ids = [1, 2]
888+ job = self.makeJob(spr=spr, bug_ids=bug_ids)
889+ self.assertProvides(job, IProcessAcceptedBugsJob)
890+ self.assertEqual(self.distroseries, job.distroseries)
891+ self.assertEqual(spr, job.sourcepackagerelease)
892+ self.assertEqual(bug_ids, job.bug_ids)
893+
894+ def test_run_raises_errors(self):
895+ # A job reports unexpected errors as exceptions.
896+ class Boom(Exception):
897+ pass
898+
899+ distroseries = self.factory.makeDistroSeries()
900+ removeSecurityProxy(distroseries).getSourcePackage = FakeMethod(
901+ failure=Boom())
902+ job = self.makeJob(distroseries=distroseries)
903+ self.assertRaises(Boom, job.run)
904+
905+ def test___repr__(self):
906+ spr = self.factory.makeSourcePackageRelease(
907+ distroseries=self.distroseries, changelog_entry="changelog")
908+ bug_ids = [1, 2]
909+ job = self.makeJob(spr=spr, bug_ids=bug_ids)
910+ self.assertEqual(
911+ ("<ProcessAcceptedBugsJob to close bugs [1, 2] for "
912+ "{spr.name}/{spr.version} ({distroseries.distribution.name} "
913+ "{distroseries.name})>").format(
914+ distroseries=self.distroseries, spr=spr),
915+ repr(job))
916+
917+ def test_run(self):
918+ # A proper test run closes bugs.
919+ spr = self.factory.makeSourcePackageRelease(
920+ distroseries=self.distroseries, changelog_entry="changelog")
921+ bug = self.factory.makeBug()
922+ bugtask = self.factory.makeBugTask(target=spr.sourcepackage, bug=bug)
923+ self.assertEqual(BugTaskStatus.NEW, bugtask.status)
924+ job = self.makeJob(spr=spr, bug_ids=[bug.id])
925+ JobRunner([job]).runAll()
926+ self.assertEqual(BugTaskStatus.FIXRELEASED, bugtask.status)
927+
928+ def test_smoke(self):
929+ spr = self.factory.makeSourcePackageRelease(
930+ distroseries=self.distroseries, changelog_entry="changelog")
931+ bug = self.factory.makeBug()
932+ bugtask = self.factory.makeBugTask(target=spr.sourcepackage, bug=bug)
933+ self.assertEqual(BugTaskStatus.NEW, bugtask.status)
934+ self.makeJob(spr=spr, bug_ids=[bug.id])
935+ transaction.commit()
936+
937+ out, err, exit_code = run_script(
938+ "LP_DEBUG_SQL=1 cronscripts/process-job-source.py -vv %s" % (
939+ IProcessAcceptedBugsJobSource.getName()))
940+
941+ self.addDetail("stdout", text_content(out))
942+ self.addDetail("stderr", text_content(err))
943+
944+ self.assertEqual(0, exit_code)
945+ self.assertEqual(BugTaskStatus.FIXRELEASED, bugtask.status)
946+
947+
948+class TestViaCelery(TestCaseWithFactory):
949+ """ProcessAcceptedBugsJob runs under Celery."""
950+
951+ layer = CeleryJobLayer
952+
953+ def test_run(self):
954+ # A proper test run closes bugs.
955+ self.useFixture(FeatureFixture({
956+ "jobs.celery.enabled_classes": "ProcessAcceptedBugsJob",
957+ }))
958+
959+ distroseries = self.factory.makeDistroSeries()
960+ spr = self.factory.makeSourcePackageRelease(
961+ distroseries=distroseries, changelog_entry="changelog")
962+ bug = self.factory.makeBug()
963+ bugtask = self.factory.makeBugTask(target=spr.sourcepackage, bug=bug)
964+ self.assertEqual(BugTaskStatus.NEW, bugtask.status)
965+ job = getUtility(IProcessAcceptedBugsJobSource).create(
966+ distroseries, spr, [bug.id])
967+ self.assertEqual(distroseries, job.distroseries)
968+ self.assertEqual(spr, job.sourcepackagerelease)
969+ self.assertEqual([bug.id], job.bug_ids)
970+
971+ with block_on_job(self):
972+ transaction.commit()
973+
974+ self.assertEqual(JobStatus.COMPLETED, job.status)
975+ self.assertEqual(BugTaskStatus.FIXRELEASED, bugtask.status)