Merge lp:~cjwatson/launchpad/git-update-related-bugs into lp:launchpad
- git-update-related-bugs
- Merge into devel
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 | ||||
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.
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. |
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.