Merge lp:~cjwatson/launchpad/git-update-related-bugs into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18180
Proposed branch: lp:~cjwatson/launchpad/git-update-related-bugs
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/bmp-buglinktarget-ui
Diff against target: 879 lines (+485/-35)
16 files modified
lib/lp/answers/model/question.py (+6/-3)
lib/lp/blueprints/model/specification.py (+5/-3)
lib/lp/bugs/interfaces/buglink.py (+2/-2)
lib/lp/bugs/model/buglinktarget.py (+4/-4)
lib/lp/bugs/model/cve.py (+5/-3)
lib/lp/code/interfaces/branchmergeproposal.py (+6/-0)
lib/lp/code/interfaces/gitrepository.py (+5/-2)
lib/lp/code/model/branchmergeproposal.py (+89/-4)
lib/lp/code/model/gitrepository.py (+2/-1)
lib/lp/code/model/tests/test_branchmergeproposal.py (+143/-0)
lib/lp/code/model/tests/test_gitjob.py (+1/-0)
lib/lp/code/model/tests/test_gitref.py (+175/-0)
lib/lp/code/model/tests/test_gitrepository.py (+33/-9)
lib/lp/code/subscribers/branchmergeproposal.py (+4/-3)
lib/lp/code/subscribers/git.py (+1/-1)
lib/lp/services/config/schema-lazr.conf (+4/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-update-related-bugs
Reviewer Review Type Date Requested Status
Celso Providelo (community) Approve
Launchpad code reviewers Pending
Review via email: mp+298369@code.launchpad.net

Commit message

Automatically update related bugs for Git-based merge proposals based on their commit logs.

Description of the change

Automatically update related bugs for Git-based merge proposals based on their commit logs.

I've intentionally used the same regexes that Soyuz uses to parse changelogs, for compatibility with package branches and because it's reasonably compact in commit messages.

There's a corner case here with prerequisite branches where we may end up with too many related bugs, noted in an XXX comment. I think it's tolerable for the moment.

To post a comment you must log in.
Revision history for this message
Celso Providelo (cprov) wrote :

Thanks, Colin.

While I agree that linking to bugs referred from prerequisite branches is tolerable, I wonder if we can chain the change in turnip to fix this problem soon-ish.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/answers/model/question.py'
2--- lib/lp/answers/model/question.py 2015-10-08 08:59:00 +0000
3+++ lib/lp/answers/model/question.py 2016-09-02 14:40:37 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Question models."""
10@@ -669,11 +669,14 @@
11 bulk.load(Bug, bug_ids), key=operator.attrgetter('id')))
12
13 # IBugLinkTarget implementation
14- def createBugLink(self, bug):
15+ def createBugLink(self, bug, props=None):
16 """See BugLinkTargetMixin."""
17+ if props is None:
18+ props = {}
19 # XXX: Should set creator.
20 getUtility(IXRefSet).create(
21- {(u'question', unicode(self.id)): {(u'bug', unicode(bug.id)): {}}})
22+ {(u'question', unicode(self.id)):
23+ {(u'bug', unicode(bug.id)): props}})
24
25 def deleteBugLink(self, bug):
26 """See BugLinkTargetMixin."""
27
28=== modified file 'lib/lp/blueprints/model/specification.py'
29--- lib/lp/blueprints/model/specification.py 2015-10-08 08:59:00 +0000
30+++ lib/lp/blueprints/model/specification.py 2016-09-02 14:40:37 +0000
31@@ -1,4 +1,4 @@
32-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
33+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
34 # GNU Affero General Public License version 3 (see the file LICENSE).
35
36 __metaclass__ = type
37@@ -800,12 +800,14 @@
38 return list(sorted(
39 bulk.load(Bug, bug_ids), key=operator.attrgetter('id')))
40
41- def createBugLink(self, bug):
42+ def createBugLink(self, bug, props=None):
43 """See BugLinkTargetMixin."""
44+ if props is None:
45+ props = {}
46 # XXX: Should set creator.
47 getUtility(IXRefSet).create(
48 {(u'specification', unicode(self.id)):
49- {(u'bug', unicode(bug.id)): {}}})
50+ {(u'bug', unicode(bug.id)): props}})
51
52 def deleteBugLink(self, bug):
53 """See BugLinkTargetMixin."""
54
55=== modified file 'lib/lp/bugs/interfaces/buglink.py'
56--- lib/lp/bugs/interfaces/buglink.py 2015-09-30 01:51:52 +0000
57+++ lib/lp/bugs/interfaces/buglink.py 2016-09-02 14:40:37 +0000
58@@ -1,4 +1,4 @@
59-# Copyright 2009 Canonical Ltd. This software is licensed under the
60+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
61 # GNU Affero General Public License version 3 (see the file LICENSE).
62
63 """Interfaces for objects that can be linked to bugs."""
64@@ -69,7 +69,7 @@
65 value_type=Reference(schema=IBug), readonly=True),
66 as_of="devel")
67
68- def linkBug(bug, user=None, check_permissions=True):
69+ def linkBug(bug, user=None, check_permissions=True, props=None):
70 """Link the object with this bug.
71
72 If a new link is created by this method, an ObjectLinkedEvent is
73
74=== modified file 'lib/lp/bugs/model/buglinktarget.py'
75--- lib/lp/bugs/model/buglinktarget.py 2016-02-06 01:41:00 +0000
76+++ lib/lp/bugs/model/buglinktarget.py 2016-09-02 14:40:37 +0000
77@@ -1,4 +1,4 @@
78-# Copyright 2009 Canonical Ltd. This software is licensed under the
79+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
80 # GNU Affero General Public License version 3 (see the file LICENSE).
81
82 __metaclass__ = type
83@@ -41,7 +41,7 @@
84 class BugLinkTargetMixin:
85 """Mixin class for IBugLinkTarget implementation."""
86
87- def createBugLink(self, bug):
88+ def createBugLink(self, bug, props=None):
89 """Subclass should override that method to create a BugLink instance.
90 """
91 raise NotImplementedError("missing createBugLink() implementation")
92@@ -52,14 +52,14 @@
93 raise NotImplementedError("missing deleteBugLink() implementation")
94
95 # IBugLinkTarget implementation
96- def linkBug(self, bug, user=None, check_permissions=True):
97+ def linkBug(self, bug, user=None, check_permissions=True, props=None):
98 """See IBugLinkTarget."""
99 if check_permissions and not bug.userCanView(user):
100 raise Unauthorized(
101 "Cannot link a private bug you don't have access to")
102 if bug in self.bugs:
103 return False
104- self.createBugLink(bug)
105+ self.createBugLink(bug, props=props)
106 notify(ObjectLinkedEvent(bug, self, user=user))
107 notify(ObjectLinkedEvent(self, bug, user=user))
108 return True
109
110=== modified file 'lib/lp/bugs/model/cve.py'
111--- lib/lp/bugs/model/cve.py 2015-09-28 12:25:52 +0000
112+++ lib/lp/bugs/model/cve.py 2016-09-02 14:40:37 +0000
113@@ -1,4 +1,4 @@
114-# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
115+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
116 # GNU Affero General Public License version 3 (see the file LICENSE).
117
118 __metaclass__ = type
119@@ -91,11 +91,13 @@
120 assert ref.cve == self
121 CveReference.delete(ref.id)
122
123- def createBugLink(self, bug):
124+ def createBugLink(self, bug, props=None):
125 """See BugLinkTargetMixin."""
126+ if props is None:
127+ props = {}
128 # XXX: Should set creator.
129 getUtility(IXRefSet).create(
130- {(u'cve', self.sequence): {(u'bug', unicode(bug.id)): {}}})
131+ {(u'cve', self.sequence): {(u'bug', unicode(bug.id)): props}})
132
133 def deleteBugLink(self, bug):
134 """See BugLinkTargetMixin."""
135
136=== modified file 'lib/lp/code/interfaces/branchmergeproposal.py'
137--- lib/lp/code/interfaces/branchmergeproposal.py 2016-06-25 08:05:06 +0000
138+++ lib/lp/code/interfaces/branchmergeproposal.py 2016-09-02 14:40:37 +0000
139@@ -386,6 +386,12 @@
140 def getRelatedBugTasks(user):
141 """Return the Bug tasks related to this merge proposal."""
142
143+ def updateRelatedBugsFromSource():
144+ """Update related bug links based on commits in the source branch.
145+
146+ This is currently only meaningful for Git-based merge proposals.
147+ """
148+
149 def getRevisionsSinceReviewStart():
150 """Return all the revisions added since the review began.
151
152
153=== modified file 'lib/lp/code/interfaces/gitrepository.py'
154--- lib/lp/code/interfaces/gitrepository.py 2016-06-20 20:32:36 +0000
155+++ lib/lp/code/interfaces/gitrepository.py 2016-09-02 14:40:37 +0000
156@@ -508,8 +508,11 @@
157 kept up to date.
158 """
159
160- def scheduleDiffUpdates(paths):
161- """Create UpdatePreviewDiffJobs for landing targets.
162+ def updateLandingTargets(paths):
163+ """Update landing targets (MPs where this repository is the source).
164+
165+ For each merge proposal, update linked bugs and create
166+ `UpdatePreviewDiffJob`s.
167
168 :param paths: A list of reference paths. Any merge proposals whose
169 source is this repository and one of these paths will have their
170
171=== modified file 'lib/lp/code/model/branchmergeproposal.py'
172--- lib/lp/code/model/branchmergeproposal.py 2016-07-28 10:35:21 +0000
173+++ lib/lp/code/model/branchmergeproposal.py 2016-09-02 14:40:37 +0000
174@@ -12,6 +12,7 @@
175
176 from email.utils import make_msgid
177 from operator import attrgetter
178+import sys
179
180 from lazr.lifecycle.event import (
181 ObjectCreatedEvent,
182@@ -39,10 +40,12 @@
183 )
184 from storm.store import Store
185 from zope.component import getUtility
186+from zope.error.interfaces import IErrorReportingUtility
187 from zope.event import notify
188 from zope.interface import implementer
189
190 from lp.app.enums import PRIVATE_INFORMATION_TYPES
191+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
192 from lp.bugs.interfaces.bugtask import IBugTaskSet
193 from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
194 from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
195@@ -122,7 +125,12 @@
196 cachedproperty,
197 get_property_cache,
198 )
199+from lp.services.webapp.errorlog import ScriptRequest
200 from lp.services.xref.interfaces import IXRefSet
201+from lp.soyuz.enums import (
202+ re_bug_numbers,
203+ re_lp_closes,
204+ )
205
206
207 def is_valid_transition(proposal, from_state, next_state, user=None):
208@@ -178,6 +186,10 @@
209 return True
210
211
212+class TooManyRelatedBugs(Exception):
213+ """A source branch has too many related bugs linked from commits."""
214+
215+
216 @implementer(IBranchMergeProposal, IHasBranchTarget)
217 class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
218 """A relationship between a person and a branch."""
219@@ -391,12 +403,14 @@
220 return filter_bugtasks_by_context(
221 self.source_git_repository.target, tasks)
222
223- def createBugLink(self, bug):
224+ def createBugLink(self, bug, props=None):
225 """See `BugLinkTargetMixin`."""
226+ if props is None:
227+ props = {}
228 # XXX cjwatson 2016-06-11: Should set creator.
229 getUtility(IXRefSet).create(
230 {(u'merge_proposal', unicode(self.id)):
231- {(u'bug', unicode(bug.id)): {}}})
232+ {(u'bug', unicode(bug.id)): props}})
233
234 def deleteBugLink(self, bug):
235 """See `BugLinkTargetMixin`."""
236@@ -404,7 +418,7 @@
237 {(u'merge_proposal', unicode(self.id)):
238 [(u'bug', unicode(bug.id))]})
239
240- def linkBug(self, bug, user=None, check_permissions=True):
241+ def linkBug(self, bug, user=None, check_permissions=True, props=None):
242 """See `BugLinkTargetMixin`."""
243 if self.source_branch is not None:
244 # For Bazaar, we currently only store bug/branch links.
245@@ -412,7 +426,8 @@
246 else:
247 # Otherwise, link the bug to the merge proposal directly.
248 return super(BranchMergeProposal, self).linkBug(
249- bug, user=user, check_permissions=check_permissions)
250+ bug, user=user, check_permissions=check_permissions,
251+ props=props)
252
253 def unlinkBug(self, bug, user=None, check_permissions=True):
254 """See `BugLinkTargetMixin`."""
255@@ -428,6 +443,76 @@
256 return super(BranchMergeProposal, self).unlinkBug(
257 bug, user=user, check_permissions=check_permissions)
258
259+ def _reportTooManyRelatedBugs(self):
260+ properties = [
261+ ("error-explanation", (
262+ "Number of related bugs exceeds limit %d." %
263+ config.codehosting.related_bugs_from_source_limit)),
264+ ("source", self.merge_source.identity),
265+ ("target", self.merge_target.identity),
266+ ]
267+ if self.merge_prerequisite is not None:
268+ properties.append(
269+ ("prerequisite", self.merge_prerequisite.identity))
270+ request = ScriptRequest(properties)
271+ getUtility(IErrorReportingUtility).raising(sys.exc_info(), request)
272+
273+ def _fetchRelatedBugIDsFromSource(self):
274+ """Fetch related bug IDs from the source branch."""
275+ from lp.bugs.model.bug import Bug
276+ # Only currently used for Git.
277+ assert self.source_git_ref is not None
278+ # XXX cjwatson 2016-06-11: This may return too many bugs in the case
279+ # where a prerequisite branch fixes a bug which is not fixed by
280+ # further commits on the source branch. To fix this, we need
281+ # turnip's log API to be able to take multiple stop parameters.
282+ commits = self.getUnlandedSourceBranchRevisions()
283+ bug_ids = set()
284+ limit = config.codehosting.related_bugs_from_source_limit
285+ try:
286+ for commit in commits:
287+ if "commit_message" in commit:
288+ for match in re_lp_closes.finditer(
289+ commit["commit_message"]):
290+ for bug_num in re_bug_numbers.findall(match.group(0)):
291+ bug_id = int(bug_num)
292+ if bug_id not in bug_ids and len(bug_ids) == limit:
293+ raise TooManyRelatedBugs()
294+ bug_ids.add(bug_id)
295+ except TooManyRelatedBugs:
296+ self._reportTooManyRelatedBugs()
297+ # Only return bug IDs that exist.
298+ return set(IStore(Bug).find(Bug.id, Bug.id.is_in(bug_ids)))
299+
300+ def updateRelatedBugsFromSource(self):
301+ """See `IBranchMergeProposal`."""
302+ from lp.bugs.model.bug import Bug
303+ # Only currently used for Git.
304+ assert self.source_git_ref is not None
305+ current_bug_ids_from_source = {
306+ int(id): (props['metadata'] or {}).get('from_source', False)
307+ for (_, id), props in getUtility(IXRefSet).findFrom(
308+ (u'merge_proposal', unicode(self.id)), types=[u'bug']).items()}
309+ current_bug_ids = set(current_bug_ids_from_source)
310+ new_bug_ids = self._fetchRelatedBugIDsFromSource()
311+ # Only remove links marked as originating in the source branch.
312+ remove_bugs = load(Bug, set(
313+ bug_id for bug_id in current_bug_ids - new_bug_ids
314+ if current_bug_ids_from_source[bug_id]))
315+ for bug in remove_bugs:
316+ self.unlinkBug(bug, check_permissions=False)
317+ add_bugs = load(Bug, new_bug_ids - current_bug_ids)
318+ # XXX cjwatson 2016-06-11: We could perhaps set creator and
319+ # date_created based on commit information, but then we'd have to
320+ # work out what to do in the case of multiple commits referring to
321+ # the same bug, updating properties if more such commits arrive
322+ # later, etc. This is simple and does the job for now.
323+ for bug in add_bugs:
324+ self.linkBug(
325+ bug, user=getUtility(ILaunchpadCelebrities).janitor,
326+ check_permissions=False,
327+ props={'metadata': {'from_source': True}})
328+
329 @property
330 def address(self):
331 return 'mp+%d@%s' % (self.id, config.launchpad.code_domain)
332
333=== modified file 'lib/lp/code/model/gitrepository.py'
334--- lib/lp/code/model/gitrepository.py 2016-06-20 20:32:36 +0000
335+++ lib/lp/code/model/gitrepository.py 2016-09-02 14:40:37 +0000
336@@ -967,11 +967,12 @@
337 store.invalidate()
338 return updated
339
340- def scheduleDiffUpdates(self, paths):
341+ def updateLandingTargets(self, paths):
342 """See `IGitRepository`."""
343 from lp.code.model.branchmergeproposaljob import UpdatePreviewDiffJob
344 jobs = []
345 for merge_proposal in self.getActiveLandingTargets(paths):
346+ merge_proposal.updateRelatedBugsFromSource()
347 jobs.append(UpdatePreviewDiffJob.create(merge_proposal))
348 return jobs
349
350
351=== modified file 'lib/lp/code/model/tests/test_branchmergeproposal.py'
352--- lib/lp/code/model/tests/test_branchmergeproposal.py 2016-06-24 23:35:24 +0000
353+++ lib/lp/code/model/tests/test_branchmergeproposal.py 2016-09-02 14:40:37 +0000
354@@ -10,6 +10,7 @@
355 timedelta,
356 )
357 from difflib import unified_diff
358+import hashlib
359 from unittest import TestCase
360
361 from lazr.lifecycle.event import (
362@@ -21,7 +22,9 @@
363 from sqlobject import SQLObjectNotFound
364 from storm.locals import Store
365 from testtools.matchers import (
366+ ContainsDict,
367 Equals,
368+ Is,
369 MatchesDict,
370 MatchesStructure,
371 )
372@@ -57,6 +60,7 @@
373 IBranchMergeProposalGetter,
374 notify_modified,
375 )
376+from lp.code.interfaces.githosting import IGitHostingClient
377 from lp.code.model.branchmergeproposal import (
378 BranchMergeProposalGetter,
379 is_valid_transition,
380@@ -77,6 +81,7 @@
381 from lp.services.database.constants import UTC_NOW
382 from lp.services.features.testing import FeatureFixture
383 from lp.services.webapp import canonical_url
384+from lp.services.xref.interfaces import IXRefSet
385 from lp.testing import (
386 ExpectedException,
387 launchpadlib_for,
388@@ -90,6 +95,8 @@
389 )
390 from lp.testing.dbuser import dbuser
391 from lp.testing.factory import LaunchpadObjectFactory
392+from lp.testing.fakemethod import FakeMethod
393+from lp.testing.fixture import ZopeUtilityFixture
394 from lp.testing.layers import (
395 DatabaseFunctionalLayer,
396 LaunchpadFunctionalLayer,
397@@ -1463,6 +1470,142 @@
398 def _makeBranchMergeProposal(self):
399 return self.factory.makeBranchMergeProposalForGit()
400
401+ def test__fetchRelatedBugIDsFromSource(self):
402+ # _fetchRelatedBugIDsFromSource makes a reasonable backend call and
403+ # parses commit messages.
404+ bugs = [self.factory.makeBug() for _ in range(3)]
405+ bmp = self._makeBranchMergeProposal()
406+ hosting_client = FakeMethod()
407+ hosting_client.getLog = FakeMethod(result=[
408+ {
409+ u"sha1": bmp.source_git_commit_sha1,
410+ u"message": u"Commit 1\n\nLP: #%d" % bugs[0].id,
411+ },
412+ {
413+ u"sha1": unicode(hashlib.sha1("1").hexdigest()),
414+ # Will not be matched.
415+ u"message": u"Commit 2; see LP #%d" % bugs[1].id,
416+ },
417+ {
418+ u"sha1": unicode(hashlib.sha1("2").hexdigest()),
419+ u"message": u"Commit 3; LP: #%d" % bugs[2].id,
420+ },
421+ {
422+ u"sha1": unicode(hashlib.sha1("3").hexdigest()),
423+ # Non-existent bug ID will not be returned.
424+ u"message": u"Non-existent bug; LP: #%d" % (bugs[2].id + 100),
425+ },
426+ ])
427+ self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
428+ related_bugs = bmp._fetchRelatedBugIDsFromSource()
429+ path = "%s:%s" % (
430+ bmp.target_git_repository.getInternalPath(),
431+ bmp.source_git_repository.getInternalPath())
432+ self.assertEqual(
433+ [((path, bmp.source_git_commit_sha1),
434+ {"limit": 10, "stop": bmp.target_git_commit_sha1,
435+ "logger": None})],
436+ hosting_client.getLog.calls)
437+ self.assertContentEqual([bugs[0].id, bugs[2].id], related_bugs)
438+
439+ def _setUpLog(self, bugs):
440+ """Set up a fake log response referring to the given bugs."""
441+ if getattr(self, "hosting_client", None) is None:
442+ self.hosting_client = FakeMethod()
443+ self.hosting_client.getLog = FakeMethod()
444+ self.useFixture(
445+ ZopeUtilityFixture(self.hosting_client, IGitHostingClient))
446+ self.hosting_client.getLog = FakeMethod(result=[
447+ {
448+ u"sha1": unicode(hashlib.sha1(str(i)).hexdigest()),
449+ u"message": u"LP: #%d" % bug.id,
450+ }
451+ for i, bug in enumerate(bugs)])
452+
453+ def test_updateRelatedBugsFromSource_no_links(self):
454+ # updateRelatedBugsFromSource does nothing if there are no related
455+ # bugs in either the database or the source branch.
456+ bmp = self._makeBranchMergeProposal()
457+ self._setUpLog([])
458+ bmp.updateRelatedBugsFromSource()
459+ self.assertEqual([], bmp.bugs)
460+
461+ def test_updateRelatedBugsFromSource_new_links(self):
462+ # If the source branch has related bugs not yet reflected in the
463+ # database, updateRelatedBugsFromSource creates the links.
464+ bugs = [self.factory.makeBug() for _ in range(3)]
465+ bmp = self._makeBranchMergeProposal()
466+ self._setUpLog([bugs[0]])
467+ bmp.updateRelatedBugsFromSource()
468+ self.assertEqual([bugs[0]], bmp.bugs)
469+ self._setUpLog(bugs)
470+ bmp.updateRelatedBugsFromSource()
471+ self.assertContentEqual(bugs, bmp.bugs)
472+
473+ def test_updateRelatedBugsFromSource_same_links(self):
474+ # If the database and the source branch list the same related bugs,
475+ # updateRelatedBugsFromSource does nothing.
476+ bug = self.factory.makeBug()
477+ bmp = self._makeBranchMergeProposal()
478+ self._setUpLog([bug])
479+ bmp.updateRelatedBugsFromSource()
480+ self.assertEqual([bug], bmp.bugs)
481+ # The second run is a no-op.
482+ bmp.updateRelatedBugsFromSource()
483+ self.assertEqual([bug], bmp.bugs)
484+
485+ def test_updateRelatedBugsFromSource_removes_old_links(self):
486+ # If the database records a source-branch-originating related bug
487+ # that is no longer listed by the source branch,
488+ # updateRelatedBugsFromSource removes the link.
489+ bug = self.factory.makeBug()
490+ bmp = self._makeBranchMergeProposal()
491+ self._setUpLog([bug])
492+ bmp.updateRelatedBugsFromSource()
493+ self.assertEqual([bug], bmp.bugs)
494+ self._setUpLog([])
495+ bmp.updateRelatedBugsFromSource()
496+ self.assertEqual([], bmp.bugs)
497+
498+ def test_updateRelatedBugsFromSource_leaves_manual_links(self):
499+ # If a bug was linked to the merge proposal manually,
500+ # updateRelatedBugsFromSource leaves the link alone regardless of
501+ # whether it is listed by the source branch.
502+ bug = self.factory.makeBug()
503+ bmp = self._makeBranchMergeProposal()
504+ bmp.linkBug(bug)
505+ self._setUpLog([])
506+ bmp.updateRelatedBugsFromSource()
507+ self.assertEqual([bug], bmp.bugs)
508+ matches_expected_xref = MatchesDict(
509+ {(u"bug", unicode(bug.id)): ContainsDict({"metadata": Is(None)})})
510+ self.assertThat(
511+ getUtility(IXRefSet).findFrom(
512+ (u"merge_proposal", unicode(bmp.id)), types=[u"bug"]),
513+ matches_expected_xref)
514+ self._setUpLog([bug])
515+ self.assertThat(
516+ getUtility(IXRefSet).findFrom(
517+ (u"merge_proposal", unicode(bmp.id)), types=[u"bug"]),
518+ matches_expected_xref)
519+
520+ def test_updateRelatedBugsFromSource_honours_limit(self):
521+ # If the number of bugs to be linked exceeds the configured limit,
522+ # updateRelatedBugsFromSource only links that many bugs and logs an
523+ # OOPS.
524+ self.pushConfig("codehosting", related_bugs_from_source_limit=3)
525+ bugs = [self.factory.makeBug() for _ in range(5)]
526+ bmp = self._makeBranchMergeProposal()
527+ self._setUpLog([bugs[0]])
528+ bmp.updateRelatedBugsFromSource()
529+ self.assertEqual([bugs[0]], bmp.bugs)
530+ self.assertEqual([], self.oopses)
531+ self._setUpLog(bugs)
532+ bmp.updateRelatedBugsFromSource()
533+ self.assertContentEqual(bugs[:3], bmp.bugs)
534+ self.assertEqual(1, len(self.oopses))
535+ self.assertEqual("TooManyRelatedBugs", self.oopses[0]["type"])
536+
537
538 class TestNotifyModified(TestCaseWithFactory):
539
540
541=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
542--- lib/lp/code/model/tests/test_gitjob.py 2016-05-13 10:58:59 +0000
543+++ lib/lp/code/model/tests/test_gitjob.py 2016-09-02 14:40:37 +0000
544@@ -245,6 +245,7 @@
545 }},
546 }
547 hosting_client = FakeGitHostingClient(new_refs, [])
548+ hosting_client.getLog = FakeMethod(result=[])
549 hosting_client.detectMerges = FakeMethod(
550 result={source.commit_sha1: u'0' * 40})
551 self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
552
553=== modified file 'lib/lp/code/model/tests/test_gitref.py'
554--- lib/lp/code/model/tests/test_gitref.py 2016-05-28 00:21:40 +0000
555+++ lib/lp/code/model/tests/test_gitref.py 2016-09-02 14:40:37 +0000
556@@ -26,6 +26,7 @@
557 from lp.app.enums import InformationType
558 from lp.app.interfaces.informationtype import IInformationType
559 from lp.app.interfaces.launchpad import IPrivacy
560+from lp.code.errors import InvalidBranchMergeProposal
561 from lp.code.interfaces.githosting import IGitHostingClient
562 from lp.services.features.testing import FeatureFixture
563 from lp.services.memcache.interfaces import IMemcacheClient
564@@ -270,6 +271,180 @@
565 ]))
566
567
568+class TestGitRefCreateMergeProposal(TestCaseWithFactory):
569+ """Exercise all the code paths for creating a merge proposal."""
570+
571+ layer = LaunchpadFunctionalLayer
572+
573+ def setUp(self):
574+ super(TestGitRefCreateMergeProposal, self).setUp()
575+ with admin_logged_in():
576+ self.project = self.factory.makeProduct()
577+ self.user = self.factory.makePerson()
578+ self.reviewer = self.factory.makePerson(name=u"reviewer")
579+ [self.source] = self.factory.makeGitRefs(
580+ name=u"source", owner=self.user, target=self.project)
581+ [self.target] = self.factory.makeGitRefs(
582+ name=u"target", owner=self.user, target=self.project)
583+ [self.prerequisite] = self.factory.makeGitRefs(
584+ name=u"prerequisite", owner=self.user, target=self.project)
585+ self.log = []
586+ self.hosting_client = FakeMethod()
587+ self.hosting_client.getLog = FakeMethod(result=self.log)
588+ self.useFixture(
589+ ZopeUtilityFixture(self.hosting_client, IGitHostingClient))
590+
591+ def assertOnePendingReview(self, proposal, reviewer, review_type=None):
592+ # There should be one pending vote for the reviewer with the specified
593+ # review type.
594+ [vote] = proposal.votes
595+ self.assertEqual(reviewer, vote.reviewer)
596+ self.assertEqual(self.user, vote.registrant)
597+ self.assertIsNone(vote.comment)
598+ if review_type is None:
599+ self.assertIsNone(vote.review_type)
600+ else:
601+ self.assertEqual(review_type, vote.review_type)
602+
603+ def test_personal_source(self):
604+ """Personal repositories cannot be used as a source for MPs."""
605+ self.source.repository.setTarget(
606+ target=self.source.owner, user=self.source.owner)
607+ self.assertRaises(
608+ InvalidBranchMergeProposal, self.source.addLandingTarget,
609+ self.user, self.target)
610+
611+ def test_target_repository_same_target(self):
612+ """The target repository's target must match that of the source."""
613+ self.target.repository.setTarget(
614+ target=self.target.owner, user=self.target.owner)
615+ self.assertRaises(
616+ InvalidBranchMergeProposal, self.source.addLandingTarget,
617+ self.user, self.target)
618+
619+ project = self.factory.makeProduct()
620+ self.target.repository.setTarget(
621+ target=project, user=self.target.owner)
622+ self.assertRaises(
623+ InvalidBranchMergeProposal, self.source.addLandingTarget,
624+ self.user, self.target)
625+
626+ def test_target_must_not_be_the_source(self):
627+ """The target and source references cannot be the same."""
628+ self.assertRaises(
629+ InvalidBranchMergeProposal, self.source.addLandingTarget,
630+ self.user, self.source)
631+
632+ def test_prerequisite_repository_same_target(self):
633+ """The prerequisite repository, if any, must be for the same target."""
634+ self.prerequisite.repository.setTarget(
635+ target=self.prerequisite.owner, user=self.prerequisite.owner)
636+ self.assertRaises(
637+ InvalidBranchMergeProposal, self.source.addLandingTarget,
638+ self.user, self.target, self.prerequisite)
639+
640+ project = self.factory.makeProduct()
641+ self.prerequisite.repository.setTarget(
642+ target=project, user=self.prerequisite.owner)
643+ self.assertRaises(
644+ InvalidBranchMergeProposal, self.source.addLandingTarget,
645+ self.user, self.target, self.prerequisite)
646+
647+ def test_prerequisite_must_not_be_the_source(self):
648+ """The target and source references cannot be the same."""
649+ self.assertRaises(
650+ InvalidBranchMergeProposal, self.source.addLandingTarget,
651+ self.user, self.target, self.source)
652+
653+ def test_prerequisite_must_not_be_the_target(self):
654+ """The target and source references cannot be the same."""
655+ self.assertRaises(
656+ InvalidBranchMergeProposal, self.source.addLandingTarget,
657+ self.user, self.target, self.target)
658+
659+ def test_existingMergeProposal(self):
660+ """If there is an existing merge proposal for the source and target
661+ reference pair, then another landing target specifying the same pair
662+ raises.
663+ """
664+ self.source.addLandingTarget(self.user, self.target, self.prerequisite)
665+ self.assertRaises(
666+ InvalidBranchMergeProposal, self.source.addLandingTarget,
667+ self.user, self.target, self.prerequisite)
668+
669+ def test_existingRejectedMergeProposal(self):
670+ """If there is an existing rejected merge proposal for the source
671+ and target reference pair, then another landing target specifying
672+ the same pair is fine.
673+ """
674+ proposal = self.source.addLandingTarget(
675+ self.user, self.target, self.prerequisite)
676+ proposal.rejectBranch(self.user, "some_revision")
677+ self.source.addLandingTarget(self.user, self.target, self.prerequisite)
678+
679+ def test_default_reviewer(self):
680+ """If the target repository has a default reviewer set, this
681+ reviewer should be assigned to the merge proposal.
682+ """
683+ [target_with_default_reviewer] = self.factory.makeGitRefs(
684+ name=u"target-branch-with-reviewer", owner=self.user,
685+ target=self.project, reviewer=self.reviewer)
686+ proposal = self.source.addLandingTarget(
687+ self.user, target_with_default_reviewer)
688+ self.assertOnePendingReview(proposal, self.reviewer)
689+
690+ def test_default_reviewer_when_owner(self):
691+ """If the target repository has a no default reviewer set, the
692+ repository owner should be assigned as the reviewer for the merge
693+ proposal.
694+ """
695+ proposal = self.source.addLandingTarget(self.user, self.target)
696+ self.assertOnePendingReview(proposal, self.source.owner)
697+
698+ def test_attribute_assignment(self):
699+ """Smoke test to make sure the assignments are there."""
700+ commit_message = u"Some commit message"
701+ proposal = self.source.addLandingTarget(
702+ self.user, self.target, self.prerequisite,
703+ commit_message=commit_message)
704+ self.assertEqual(self.user, proposal.registrant)
705+ self.assertEqual(self.source, proposal.merge_source)
706+ self.assertEqual(self.target, proposal.merge_target)
707+ self.assertEqual(self.prerequisite, proposal.merge_prerequisite)
708+ self.assertEqual(commit_message, proposal.commit_message)
709+
710+ def test_createMergeProposal_with_reviewers(self):
711+ person1 = self.factory.makePerson()
712+ person2 = self.factory.makePerson()
713+ e = self.assertRaises(
714+ ValueError, self.source.createMergeProposal,
715+ self.user, self.target, reviewers=[person1, person2])
716+ self.assertEqual(
717+ "reviewers and review_types must be equal length.", str(e))
718+ e = self.assertRaises(
719+ ValueError, self.source.createMergeProposal,
720+ self.user, self.target, reviewers=[person1, person2],
721+ review_types=["review1"])
722+ self.assertEqual(
723+ "reviewers and review_types must be equal length.", str(e))
724+ bmp = self.source.createMergeProposal(
725+ self.user, self.target, reviewers=[person1, person2],
726+ review_types=["review1", "review2"])
727+ votes = {(vote.reviewer, vote.review_type) for vote in bmp.votes}
728+ self.assertEqual({(person1, "review1"), (person2, "review2")}, votes)
729+
730+ def test_updates_related_bugs(self):
731+ """The merge proposal has its related bugs updated."""
732+ bug = self.factory.makeBug()
733+ self.log.append({
734+ u"sha1": unicode(hashlib.sha1("tip").hexdigest()),
735+ u"message": u"Fix upside-down messages\n\nLP: #%d" % bug.id,
736+ })
737+ proposal = self.source.addLandingTarget(self.user, self.target)
738+ self.assertEqual([bug], proposal.bugs)
739+ self.assertEqual([proposal], bug.linked_merge_proposals)
740+
741+
742 class TestGitRefWebservice(TestCaseWithFactory):
743 """Tests for the webservice."""
744
745
746=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
747--- lib/lp/code/model/tests/test_gitrepository.py 2016-07-01 20:33:06 +0000
748+++ lib/lp/code/model/tests/test_gitrepository.py 2016-09-02 14:40:37 +0000
749@@ -986,7 +986,7 @@
750 class TestGitRepositoryRefs(TestCaseWithFactory):
751 """Tests for ref handling."""
752
753- layer = DatabaseFunctionalLayer
754+ layer = LaunchpadFunctionalLayer
755
756 def test__convertRefInfo(self):
757 # _convertRefInfo converts a valid info dictionary.
758@@ -1107,6 +1107,9 @@
759 return [UpdatePreviewDiffJob(job) for job in jobs]
760
761 def test_update_schedules_diff_update(self):
762+ hosting_client = FakeMethod()
763+ hosting_client.getLog = FakeMethod(result=[])
764+ self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
765 repository = self.factory.makeGitRepository()
766 [ref] = self.factory.makeGitRefs(repository=repository)
767 self.assertRefsMatch(repository.refs, repository, [ref.path])
768@@ -1816,23 +1819,44 @@
769 self.assertNotEqual(ref2_2, bmp2.source_git_ref)
770
771
772-class TestGitRepositoryScheduleDiffUpdates(TestCaseWithFactory):
773-
774- layer = DatabaseFunctionalLayer
775-
776- def test_scheduleDiffUpdates(self):
777+class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory):
778+
779+ layer = LaunchpadFunctionalLayer
780+
781+ def test_updates_related_bugs(self):
782+ """All non-final merge proposals have their related bugs updated."""
783+ bug = self.factory.makeBug()
784+ bmp = self.factory.makeBranchMergeProposalForGit()
785+ self.assertEqual([], bmp.bugs)
786+ self.assertEqual([], bug.linked_merge_proposals)
787+ hosting_client = FakeMethod()
788+ hosting_client.getLog = FakeMethod(result=[
789+ {
790+ u"sha1": bmp.source_git_commit_sha1,
791+ u"message": u"Fix upside-down messages\n\nLP: #%d" % bug.id,
792+ },
793+ ])
794+ self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
795+ bmp.source_git_repository.updateLandingTargets([bmp.source_git_path])
796+ self.assertEqual([bug], bmp.bugs)
797+ self.assertEqual([bmp], bug.linked_merge_proposals)
798+
799+ def test_schedules_diff_updates(self):
800 """Create jobs for all merge proposals."""
801+ hosting_client = FakeMethod()
802+ hosting_client.getLog = FakeMethod(result=[])
803+ self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
804 bmp1 = self.factory.makeBranchMergeProposalForGit()
805 bmp2 = self.factory.makeBranchMergeProposalForGit(
806 source_ref=bmp1.source_git_ref)
807- jobs = bmp1.source_git_repository.scheduleDiffUpdates(
808+ jobs = bmp1.source_git_repository.updateLandingTargets(
809 [bmp1.source_git_path])
810 self.assertEqual(2, len(jobs))
811 bmps_to_update = [
812 removeSecurityProxy(job).branch_merge_proposal for job in jobs]
813 self.assertContentEqual([bmp1, bmp2], bmps_to_update)
814
815- def test_scheduleDiffUpdates_ignores_final(self):
816+ def test_ignores_final(self):
817 """Diffs for proposals in final states aren't updated."""
818 [source_ref] = self.factory.makeGitRefs()
819 for state in FINAL_STATES:
820@@ -1843,7 +1867,7 @@
821 for bmp in source_ref.landing_targets:
822 if bmp.queue_status not in FINAL_STATES:
823 removeSecurityProxy(bmp).deleteProposal()
824- jobs = source_ref.repository.scheduleDiffUpdates([source_ref.path])
825+ jobs = source_ref.repository.updateLandingTargets([source_ref.path])
826 self.assertEqual(0, len(jobs))
827
828
829
830=== modified file 'lib/lp/code/subscribers/branchmergeproposal.py'
831--- lib/lp/code/subscribers/branchmergeproposal.py 2015-10-19 10:21:11 +0000
832+++ lib/lp/code/subscribers/branchmergeproposal.py 2016-09-02 14:40:37 +0000
833@@ -1,4 +1,4 @@
834-# Copyright 2010-2015 Canonical Ltd. This software is licensed under the
835+# Copyright 2010-2016 Canonical Ltd. This software is licensed under the
836 # GNU Affero General Public License version 3 (see the file LICENSE).
837
838 """Event subscribers for branch merge proposals."""
839@@ -63,9 +63,10 @@
840 def merge_proposal_created(merge_proposal, event):
841 """A new merge proposal has been created.
842
843- Create a job to update the diff for the merge proposal.
844- Also create a job to email the subscribers about the new proposal.
845+ Update related bug links based on commits in the source branch; create a
846+ job to update the diff for the merge proposal; trigger webhooks.
847 """
848+ merge_proposal.updateRelatedBugsFromSource()
849 getUtility(IUpdatePreviewDiffJobSource).create(merge_proposal)
850 if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):
851 payload = {
852
853=== modified file 'lib/lp/code/subscribers/git.py'
854--- lib/lp/code/subscribers/git.py 2016-06-20 20:32:36 +0000
855+++ lib/lp/code/subscribers/git.py 2016-09-02 14:40:37 +0000
856@@ -9,7 +9,7 @@
857 def refs_updated(repository, event):
858 """Some references in a Git repository have been updated."""
859 repository.updateMergeCommitIDs(event.paths)
860- repository.scheduleDiffUpdates(event.paths)
861+ repository.updateLandingTargets(event.paths)
862 repository.markRecipesStale(event.paths)
863 repository.markSnapsStale(event.paths)
864 repository.detectMerges(event.paths, logger=event.logger)
865
866=== modified file 'lib/lp/services/config/schema-lazr.conf'
867--- lib/lp/services/config/schema-lazr.conf 2016-07-18 17:28:42 +0000
868+++ lib/lp/services/config/schema-lazr.conf 2016-09-02 14:40:37 +0000
869@@ -368,6 +368,10 @@
870 # datatype: urlbase
871 git_ssh_root: none
872
873+# The upper limit on the number of bugs to link to a merge proposal based on
874+# Git commit metadata.
875+related_bugs_from_source_limit: 1000
876+
877
878 [codeimport]
879 # Where the Bazaar imports are stored.