Merge lp:~abentley/launchpad/incremental-diffs into lp:launchpad/db-devel

Proposed by Aaron Bentley
Status: Merged
Approved by: Leonard Richardson
Approved revision: no longer in the source branch.
Merged at revision: 9950
Proposed branch: lp:~abentley/launchpad/incremental-diffs
Merge into: lp:launchpad/db-devel
Diff against target: 462 lines (+271/-5)
8 files modified
lib/lp/code/configure.zcml (+7/-1)
lib/lp/code/interfaces/diff.py (+19/-0)
lib/lp/code/model/branchmergeproposal.py (+41/-1)
lib/lp/code/model/diff.py (+66/-3)
lib/lp/code/model/tests/test_branchmergeproposal.py (+41/-0)
lib/lp/code/model/tests/test_diff.py (+84/-0)
lib/lp/testing/factory.py (+12/-0)
utilities/sourcedeps.conf (+1/-0)
To merge this branch: bzr merge lp:~abentley/launchpad/incremental-diffs
Reviewer Review Type Date Requested Status
Leonard Richardson (community) Approve
Review via email: mp+36875@code.launchpad.net

Commit message

Implement incremental diff generation.

Description of the change

= Summary =
Implement incremental diff generation

== Proposed fix ==
See above

== Pre-implementation notes ==
Mid-implementation with thumper

== Implementation details ==
The generation of incremental diffs is handled by the Difftacular bzr plugin,
and the full testing of its behaviour is provided by its test suite.

I implemented the diff_changes test method to get a clearer idea what was added
and removed by a given diff.

== Tests ==
bin/test -t TestIncrementalDiff -t TestBranchMergeProposalGetIncrementalDiffs

== Demo and Q/A ==
None

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/code/configure.zcml
  lib/lp/code/model/branchmergeproposal.py
  lib/lp/code/model/tests/test_branchmergeproposal.py
  lib/lp/code/model/diff.py
  lib/lp/testing/factory.py
  lib/lp/code/model/tests/test_diff.py
  utilities/sourcedeps.conf
  lib/lp/code/interfaces/diff.py

./lib/lp/code/model/tests/test_branchmergeproposal.py
     192: E231 missing whitespace after ','
     194: E231 missing whitespace after ','
./lib/lp/code/model/diff.py
     166: E301 expected 1 blank line, found 0

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This branch looks good with some trivial cleanup suggested in IRC, one minor change also suggested in IRC, and one major caveat:

The minor change: test_generateIncrementalDiff is pretty confusing, with lots of branches and lots of nearly-identical commits. It needs at least a comment up-front describing what's in the two revisions being diffed, and how the precursor and prerequisite branches affect this.

It might also be easier to understand if you defined a helper method to create a new branch *and its first commit*. This would make it clear which commits are setup, reflecting the "initial" states of the branches, and which commits are the ones leading up to the endpoint of the incremental diff. But an explanatory comment is good enough to get my approval.

The caveat: this branch introduces a new source dependency, difftacular, which was written by Aaron in July. Since it was written in-house, I think difftacular needs to go through the code review process, just like the lazr packages that are used in Launchpad. Fortunately, the entire difftacular project isn't much longer than 800 lines, so it shouldn't be too difficult for me to do the review.

review: Approve
Revision history for this message
Leonard Richardson (leonardr) wrote :

Oh, another change I suggested which Aaron carried through was removing the exported() calls from IIncrementalDiff. Those are for publishing fields through the web service, and IIncrementalDiff isn't being published yet.

Revision history for this message
Leonard Richardson (leonardr) wrote :

OK, I've done a review of the difftastic codebase and I see no reason why it can't be used in Launchpad. I do have one possible performance improvement, and a number of changes to make the tests more clear.

1. "foo" and "bar" are fine for filenames within the test branches,
but they really hurt comprehension when used as variable names
for the branches themselves. In the helper methods, can you call
the branches 'initial' and 'merged_in' or something that reflects
their history?

Outside the helper methods, you can call them whatever's
helpful. For instance, in test_mainline_diff, I believe
'foo' (aka 'initial') could be called 'mainline' and 'bar' (aka
'merged_in') could be called 'ignored'.

2. typo: "emtpy commit"

3. test_diff_ignore_branches calls
create_unignored_merge_scenario() to do its setup. This sounds
like a contradiction, but we're actually checking that only
*specified* branches are ignored. A docstring would make it clear
that we're checking that the diff ignores the merge of the "bar"
branch, but not the merge of the "unignored" branch. Similarly
for ignore_old_revisions--what's being ignored and what's being
included? It's hard to figure out.

Making the commits that are being ignored really obviously
destructive would also help.

3. In generate_diff.py#apply_merges(), do you need to call
merger.make_preview_transform and transform.get_preview_tree
within the for loop? Unless those methods modify 'merger'
in-place, I think they can be moved outside and run only once.

Revision history for this message
Aaron Bentley (abentley) wrote :

1. "foo" is actually the tree whose branch will be diffed. I don't think "initial" describes that any better than "foo". "merged_in" is fine, but I think "merge_input" is better.

2. Sure

3. Yes, I need it. The output of get_preview_tree is fed back into the subsequent merge. Without that, you would generate a bogus merge and likely violate the TreeTransform API.

Revision history for this message
Brad Crittenden (bac) wrote :

Is this approved branch ready to land or are you blocked? If you have abandoned the work please change the MP status to "Rejected."

Revision history for this message
Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

On 11/04/2010 11:08 PM, Brad Crittenden wrote:
> Is this approved branch ready to land or are you blocked? If you have abandoned the work please change the MP status to "Rejected."

These changes will be landed as part of the follow-on branch
incremental-diff-jobs. Please see my comment at
https://code.launchpad.net/~abentley/launchpad/incremental-diff-job/+merge/36881
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.10 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org/

iEYEARECAAYFAkzUBVsACgkQ0F+nu1YWqI285wCfQd6GoKGnYBtp37QgqMIs2RvA
9n4AnRU1u4d8MDX+jvBBF5vWqoQsJeYE
=mGMI
-----END PGP SIGNATURE-----

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added symlink 'bzrplugins/difftacular'
2=== target is u'../sourcecode/difftacular/'
3=== modified file 'lib/lp/code/configure.zcml'
4--- lib/lp/code/configure.zcml 2010-10-26 13:52:43 +0000
5+++ lib/lp/code/configure.zcml 2010-10-29 15:33:57 +0000
6@@ -233,6 +233,7 @@
7 review_diff
8 next_preview_diff_job
9 preview_diff
10+ getIncrementalDiffs
11 votes
12 all_comments
13 related_bugs
14@@ -245,7 +246,8 @@
15 isValidTransition
16 getUnlandedSourceBranchRevisions
17 root_message_id
18- getUsersVoteReference"/>
19+ getUsersVoteReference
20+ generateIncrementalDiff"/>
21 <allow interface="lp.code.interfaces.branchtarget.IHasBranchTarget"/>
22 <require
23 permission="launchpad.Edit"
24@@ -879,6 +881,10 @@
25 <class class="lp.code.model.diff.Diff">
26 <allow interface="lp.code.interfaces.diff.IDiff" />
27 </class>
28+ <class class="lp.code.model.diff.IncrementalDiff">
29+ <allow interface="lp.code.interfaces.diff.IDiff" />
30+ <allow interface="lp.code.interfaces.diff.IIncrementalDiff" />
31+ </class>
32 <class class="lp.code.model.diff.StaticDiff">
33 <allow interface="lp.code.interfaces.diff.IStaticDiff" />
34 </class>
35
36=== modified file 'lib/lp/code/interfaces/diff.py'
37--- lib/lp/code/interfaces/diff.py 2010-08-20 20:31:18 +0000
38+++ lib/lp/code/interfaces/diff.py 2010-10-29 15:33:57 +0000
39@@ -9,6 +9,7 @@
40
41 __all__ = [
42 'IDiff',
43+ 'IIncrementalDiff',
44 'IPreviewDiff',
45 'IStaticDiff',
46 'IStaticDiffSource',
47@@ -29,6 +30,7 @@
48 )
49
50 from canonical.launchpad import _
51+from lp.code.interfaces.revision import IRevision
52
53
54 class IDiff(Interface):
55@@ -62,6 +64,23 @@
56 readonly=True))
57
58
59+class IIncrementalDiff(Interface):
60+ """An incremental diff for a merge proposal."""
61+
62+ diff = Reference(IDiff, title=_('The Diff object.'), readonly=True)
63+
64+ # The schema for the Reference gets patched in _schema_circular_imports.
65+ branch_merge_proposal = Reference(
66+ Interface, readonly=True,
67+ title=_('The branch merge proposal that diff relates to.'))
68+
69+ old_revision = Reference(
70+ IRevision, readonly=True, title=_('The old revision of the diff.'))
71+
72+ new_revision = Reference(
73+ IRevision, readonly=True, title=_('The new revision of the diff.'))
74+
75+
76 class IStaticDiff(Interface):
77 """A diff with a fixed value, i.e. between two revisions."""
78
79
80=== modified file 'lib/lp/code/model/branchmergeproposal.py'
81--- lib/lp/code/model/branchmergeproposal.py 2010-10-19 00:44:24 +0000
82+++ lib/lp/code/model/branchmergeproposal.py 2010-10-29 15:33:57 +0000
83@@ -50,6 +50,7 @@
84 SQLBase,
85 sqlvalues,
86 )
87+from canonical.launchpad.interfaces.lpstorm import IMasterStore
88 from lp.code.enums import (
89 BranchMergeProposalStatus,
90 CodeReviewVote,
91@@ -78,7 +79,7 @@
92 from lp.code.model.branchrevision import BranchRevision
93 from lp.code.model.codereviewcomment import CodeReviewComment
94 from lp.code.model.codereviewvote import CodeReviewVoteReference
95-from lp.code.model.diff import PreviewDiff
96+from lp.code.model.diff import Diff, IncrementalDiff, PreviewDiff
97 from lp.registry.interfaces.person import (
98 IPerson,
99 validate_public_person,
100@@ -783,6 +784,45 @@
101 Store.of(self).flush()
102 return self.preview_diff
103
104+ def generateIncrementalDiff(self, old_revision, new_revision, diff=None):
105+ """Generate an incremental diff for the merge proposal.
106+
107+ :param old_revision: The `Revision` to generate the diff from.
108+ :param new_revision: The `Revision` to generate the diff to.
109+ :param diff: If supplied, a pregenerated `Diff`.
110+ """
111+ if diff is None:
112+ source_branch = self.source_branch.getBzrBranch()
113+ ignore_branches = [self.target_branch.getBzrBranch()]
114+ if self.prerequisite_branch is not None:
115+ ignore_branches.append(
116+ self.prerequisite_branch.getBzrBranch())
117+ diff = Diff.generateIncrementalDiff(
118+ old_revision, new_revision, source_branch, ignore_branches)
119+ incremental_diff = IncrementalDiff()
120+ incremental_diff.diff = diff
121+ incremental_diff.branch_merge_proposal = self
122+ incremental_diff.old_revision = old_revision
123+ incremental_diff.new_revision = new_revision
124+ IMasterStore(IncrementalDiff).add(incremental_diff)
125+ return incremental_diff
126+
127+ def getIncrementalDiffs(self, revision_list):
128+ """Return a list of diffs for the specified revisions.
129+
130+ :param revision_list: A list of tuples of (`Revision`, `Revision`).
131+ The first revision in the tuple is the old revision. The second
132+ is the new revision.
133+ :return: A list of IncrementalDiffs in the same order as the supplied
134+ Revisions.
135+ """
136+ diffs = Store.of(self).find(IncrementalDiff,
137+ IncrementalDiff.branch_merge_proposal_id == self.id)
138+ diff_dict = dict(
139+ ((diff.old_revision, diff.new_revision), diff)
140+ for diff in diffs)
141+ return [diff_dict.get(revisions) for revisions in revision_list]
142+
143 @property
144 def revision_end_date(self):
145 """The cutoff date for showing revisions.
146
147=== modified file 'lib/lp/code/model/diff.py'
148--- lib/lp/code/model/diff.py 2010-10-03 15:30:06 +0000
149+++ lib/lp/code/model/diff.py 2010-10-29 15:33:57 +0000
150@@ -4,10 +4,11 @@
151 """Implementation classes for IDiff, etc."""
152
153 __metaclass__ = type
154-__all__ = ['Diff', 'PreviewDiff', 'StaticDiff']
155+__all__ = ['Diff', 'IncrementalDiff', 'PreviewDiff', 'StaticDiff']
156
157+from contextlib import nested
158+from cStringIO import StringIO
159 import sys
160-from cStringIO import StringIO
161
162 from uuid import uuid1
163
164@@ -18,6 +19,7 @@
165 parse_patches,
166 Patch,
167 )
168+from bzrlib.plugins.difftacular.generate_diff import diff_ignore_branches
169 from lazr.delegates import delegates
170 import simplejson
171 from sqlobject import (
172@@ -44,10 +46,12 @@
173 from lp.app.errors import NotFoundError
174 from lp.code.interfaces.diff import (
175 IDiff,
176+ IIncrementalDiff,
177 IPreviewDiff,
178 IStaticDiff,
179 IStaticDiffSource,
180 )
181+from lp.codehosting.bzrutils import read_locked
182
183
184 class Diff(SQLBase):
185@@ -194,9 +198,14 @@
186 diff_content = StringIO()
187 show_diff_trees(from_tree, to_tree, diff_content, old_label='',
188 new_label='')
189+ return klass.fromFileAtEnd(diff_content, filename)
190+
191+ @classmethod
192+ def fromFileAtEnd(cls, diff_content, filename=None):
193+ """Make a Diff from a file object that is currently at its end."""
194 size = diff_content.tell()
195 diff_content.seek(0)
196- return klass.fromFile(diff_content, size, filename)
197+ return cls.fromFile(diff_content, size, filename)
198
199 @classmethod
200 def fromFile(cls, diff_content, size, filename=None):
201@@ -257,6 +266,30 @@
202 file_stats[path] = tuple(patch.stats_values()[:2])
203 return file_stats
204
205+ @classmethod
206+ def generateIncrementalDiff(cls, old_revision, new_revision,
207+ source_branch, ignore_branches):
208+ """Return a Diff whose contents are an incremental diff.
209+
210+ The Diff's contents will show the changes made between old_revision
211+ and new_revision, except those changes introduced by the
212+ ignore_branches.
213+
214+ :param old_revision: The `Revision` to show changes from.
215+ :param new_revision: The `Revision` to show changes to.
216+ :param source_branch: The bzr branch containing these revisions.
217+ :param ignore_brances: A collection of branches to ignore merges from.
218+ :return: a `Diff`.
219+ """
220+ diff_content = StringIO()
221+ read_locks = [read_locked(branch) for branch in [source_branch] +
222+ ignore_branches]
223+ with nested(*read_locks):
224+ diff_ignore_branches(
225+ source_branch, ignore_branches, old_revision.revision_id,
226+ new_revision.revision_id, diff_content)
227+ return cls.fromFileAtEnd(diff_content)
228+
229
230 class StaticDiff(SQLBase):
231 """A diff from one revision to another."""
232@@ -305,6 +338,36 @@
233 diff.destroySelf()
234
235
236+class IncrementalDiff(Storm):
237+ """See `IIncrementalDiff."""
238+
239+ implements(IIncrementalDiff)
240+
241+ delegates(IDiff, context='diff')
242+
243+ __storm_table__ = 'IncrementalDiff'
244+
245+ id = Int(primary=True, allow_none=False)
246+
247+ diff_id = Int(name='diff', allow_none=False)
248+
249+ diff = Reference(diff_id, 'Diff.id')
250+
251+ branch_merge_proposal_id = Int(
252+ name='branch_merge_proposal', allow_none=False)
253+
254+ branch_merge_proposal = Reference(
255+ branch_merge_proposal_id, "BranchMergeProposal.id")
256+
257+ old_revision_id = Int(name='old_revision', allow_none=False)
258+
259+ old_revision = Reference(old_revision_id, 'Revision.id')
260+
261+ new_revision_id = Int(name='new_revision', allow_none=False)
262+
263+ new_revision = Reference(new_revision_id, 'Revision.id')
264+
265+
266 class PreviewDiff(Storm):
267 """See `IPreviewDiff`."""
268 implements(IPreviewDiff)
269
270=== modified file 'lib/lp/code/model/tests/test_branchmergeproposal.py'
271--- lib/lp/code/model/tests/test_branchmergeproposal.py 2010-10-27 14:20:21 +0000
272+++ lib/lp/code/model/tests/test_branchmergeproposal.py 2010-10-29 15:33:57 +0000
273@@ -1829,5 +1829,46 @@
274 self.assertRevisionGroups(bmp, expected_groups)
275
276
277+class TestBranchMergeProposalGetIncrementalDiffs(TestCaseWithFactory):
278+
279+ layer = LaunchpadFunctionalLayer
280+
281+ def test_getIncrementalDiffs(self):
282+ """getIncrementalDiffs returns the requested values or None.
283+
284+ None is returned if there is no IncrementalDiff for the requested
285+ revision pair and branch_merge_proposal.
286+ """
287+ bmp = self.factory.makeBranchMergeProposal()
288+ diff1 = self.factory.makeIncrementalDiff(merge_proposal=bmp)
289+ diff2 = self.factory.makeIncrementalDiff(merge_proposal=bmp)
290+ diff3 = self.factory.makeIncrementalDiff()
291+ result = bmp.getIncrementalDiffs([
292+ (diff1.old_revision, diff1.new_revision),
293+ (diff2.old_revision, diff2.new_revision),
294+ # Wrong merge proposal
295+ (diff3.old_revision, diff3.new_revision),
296+ # Mismatched revisions
297+ (diff1.old_revision, diff2.new_revision),
298+ ])
299+ self.assertEqual([diff1, diff2, None, None], result)
300+
301+ def test_getIncrementalDiffs_respects_input_order(self):
302+ """The order of the output follows the input order."""
303+ bmp = self.factory.makeBranchMergeProposal()
304+ diff1 = self.factory.makeIncrementalDiff(merge_proposal=bmp)
305+ diff2 = self.factory.makeIncrementalDiff(merge_proposal=bmp)
306+ result = bmp.getIncrementalDiffs([
307+ (diff1.old_revision, diff1.new_revision),
308+ (diff2.old_revision, diff2.new_revision),
309+ ])
310+ self.assertEqual([diff1, diff2], result)
311+ result = bmp.getIncrementalDiffs([
312+ (diff2.old_revision, diff2.new_revision),
313+ (diff1.old_revision, diff1.new_revision),
314+ ])
315+ self.assertEqual([diff2, diff1], result)
316+
317+
318 def test_suite():
319 return TestLoader().loadTestsFromName(__name__)
320
321=== modified file 'lib/lp/code/model/tests/test_diff.py'
322--- lib/lp/code/model/tests/test_diff.py 2010-10-26 15:47:24 +0000
323+++ lib/lp/code/model/tests/test_diff.py 2010-10-29 15:33:57 +0000
324@@ -12,6 +12,7 @@
325 from unittest import TestLoader
326
327 from bzrlib import trace
328+from bzrlib.patches import InsertLine, parse_patches, RemoveLine
329 import transaction
330 from zope.security.proxy import removeSecurityProxy
331
332@@ -24,6 +25,7 @@
333 from lp.app.errors import NotFoundError
334 from lp.code.interfaces.diff import (
335 IDiff,
336+ IIncrementalDiff,
337 IPreviewDiff,
338 IStaticDiff,
339 IStaticDiffSource,
340@@ -521,5 +523,87 @@
341 NotFoundError, diff.getFileByName, 'preview.diff')
342
343
344+class TestIncrementalDiff(DiffTestCase):
345+ """Test that IncrementalDiff objects work."""
346+
347+ layer = LaunchpadFunctionalLayer
348+
349+ def test_providesInterface(self):
350+ incremental_diff = self.factory.makeIncrementalDiff()
351+ verifyObject(IIncrementalDiff, incremental_diff)
352+
353+ @staticmethod
354+ def diff_changes(diff_bytes):
355+ inserted = []
356+ removed = []
357+ for patch in parse_patches(diff_bytes.splitlines(True)):
358+ for hunk in patch.hunks:
359+ for line in hunk.lines:
360+ if isinstance(line, InsertLine):
361+ inserted.append(line.contents)
362+ if isinstance(line, RemoveLine):
363+ removed.append(line.contents)
364+ return inserted, removed
365+
366+ def test_generateIncrementalDiff(self):
367+ """generateIncrementalDiff works.
368+
369+ Changes merged from the prerequisite and target are ignored in the
370+ diff.
371+
372+ We generate an incremental diff from old_revision_id to
373+ new_revision_id.
374+
375+ old_revision_id has:
376+ a
377+ b
378+ e
379+
380+ new_revision_id has:
381+ d
382+ a
383+ c
384+ e
385+ f
386+
387+ Because the prerequisite branch adds "d", this change is ignored.
388+ Because the target branch adds "f", this change is ignored.
389+ So the incremental diff shows that "c" was added and "b" was removed.
390+ """
391+ self.useBzrBranches(direct_database=True)
392+ prerequisite_branch = self.factory.makeAnyBranch()
393+ bmp = self.factory.makeBranchMergeProposal(
394+ prerequisite_branch=prerequisite_branch)
395+ target_branch = self.createBzrBranch(bmp.target_branch)
396+ old_revision_id = self.commitFile(
397+ bmp.target_branch, 'foo', 'a\nb\ne\n')
398+ old_revision = self.factory.makeRevision(rev_id=old_revision_id)
399+ source_branch = self.createBzrBranch(
400+ bmp.source_branch, target_branch)
401+ self.commitFile(bmp.source_branch, 'foo', 'a\nc\ne\n')
402+ prerequisite = self.createBzrBranch(
403+ bmp.prerequisite_branch, target_branch)
404+ prerequisite_revision = self.commitFile(bmp.prerequisite_branch,
405+ 'foo', 'd\na\nb\ne\n')
406+ merge_parent = self.commitFile(bmp.target_branch, 'foo',
407+ 'a\nb\ne\nf\n')
408+ source_branch.repository.fetch(target_branch.repository,
409+ revision_id=merge_parent)
410+ self.commitFile(
411+ bmp.source_branch, 'foo', 'a\nc\ne\nf\n', [merge_parent])
412+ source_branch.repository.fetch(prerequisite.repository,
413+ revision_id=prerequisite_revision)
414+ new_revision_id = self.commitFile(
415+ bmp.source_branch, 'foo', 'd\na\nc\ne\nf\n',
416+ [prerequisite_revision])
417+ new_revision = self.factory.makeRevision(rev_id=new_revision_id)
418+ incremental_diff = bmp.generateIncrementalDiff(
419+ old_revision, new_revision)
420+ transaction.commit()
421+ inserted, removed = self.diff_changes(incremental_diff.text)
422+ self.assertEqual(['c\n'], inserted)
423+ self.assertEqual(['b\n'], removed)
424+
425+
426 def test_suite():
427 return TestLoader().loadTestsFromName(__name__)
428
429=== modified file 'lib/lp/testing/factory.py'
430--- lib/lp/testing/factory.py 2010-10-28 09:11:36 +0000
431+++ lib/lp/testing/factory.py 2010-10-29 15:33:57 +0000
432@@ -1266,6 +1266,18 @@
433 preview_diff.target_revision_id = self.getUniqueUnicode()
434 return preview_diff
435
436+ def makeIncrementalDiff(self, merge_proposal=None, old_revision=None,
437+ new_revision=None):
438+ diff = self.makeDiff()
439+ if merge_proposal is None:
440+ merge_proposal = self.makeBranchMergeProposal()
441+ if old_revision is None:
442+ old_revision = self.makeRevision()
443+ if new_revision is None:
444+ new_revision = self.makeRevision()
445+ return merge_proposal.generateIncrementalDiff(
446+ old_revision, new_revision, diff)
447+
448 def makeStaticDiff(self):
449 return StaticDiff.acquireFromText(
450 self.getUniqueUnicode(), self.getUniqueUnicode(),
451
452=== modified file 'utilities/sourcedeps.conf'
453--- utilities/sourcedeps.conf 2010-10-25 15:23:38 +0000
454+++ utilities/sourcedeps.conf 2010-10-29 15:33:57 +0000
455@@ -5,6 +5,7 @@
456 bzr-svn lp:~launchpad-pqm/bzr-svn/devel;revno=2710
457 cscvs lp:~launchpad-pqm/launchpad-cscvs/devel;revno=432
458 dulwich lp:~launchpad-pqm/dulwich/devel;revno=424
459+difftacular lp:difftacular;revno=6
460 loggerhead lp:~launchpad-pqm/loggerhead/devel;revno=176
461 lpreview lp:~launchpad-pqm/bzr-lpreview/devel;revno=23
462 mailman lp:~launchpad-pqm/mailman/2.1;revno=976

Subscribers

People subscribed via source and target branches

to status/vote changes: