Merge lp:~blr/launchpad/bug-1334577-verbose-diff into lp:launchpad

Proposed by Kit Randel
Status: Merged
Merged at revision: 17590
Proposed branch: lp:~blr/launchpad/bug-1334577-verbose-diff
Merge into: lp:launchpad
Diff against target: 379 lines (+208/-55)
3 files modified
lib/lp/code/mail/codereviewcomment.py (+87/-13)
lib/lp/code/mail/tests/test_codereviewcomment.py (+120/-41)
versions.cfg (+1/-1)
To merge this branch: bzr merge lp:~blr/launchpad/bug-1334577-verbose-diff
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
William Grant (community) Needs Fixing
Review via email: mp+243751@code.launchpad.net

Commit message

Check for inline comments associated with patch headers, hunk headers (hunk context lines) and hunk lines, and discard irrelevant bits in branch merge proposal mail.

Description of the change

Summary
Emails notifications of inline comments on a merge proposal are currently sent with the entirety of the diff, which is needlessly verbose and can make it difficult to find the relevant code. This branch provides a re-implementation of the code responsible for this and discards patches and diff hunks that do not have associated inline comments.

Proposed fix
Check for comments associated with patch headers, hunk headers (hunk context lines) and hunk lines, and discard irrelevant bits.

Implementation details
Currently the diff text is represented as a simple list. In order to have a structural representation of the diff, parse the diff text with bzrlib.patches. Ideally bzrlib.patches would provide line numbers, but as it does not wgrant and blr decided a less intrusive approach would be better than submitting a MP for bzrlib, even if it makes the mailer code more verbose.

LOC Rationale
More lines of code here to reduce lines of code elsewhere (email!). I think we're breaking even.

Tests
./bin/test -c -m lp.code.mail.tests.test_codereviewcomment

Demo and Q/A
Running the tests locally is the best option, unless you have an MTA configured in your dev environment. I'm hoping we can test this with genuine emails on staging?

Lint
Lint free

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Needs Fixing (code)
Revision history for this message
Colin Watson (cjwatson) wrote :

This looks almost right now, but there seems to be a minor issue or two left over from William's earlier review, and I spotted what looks like a logic bug that you'll want to fix.

review: Needs Fixing
Revision history for this message
Colin Watson (cjwatson) wrote :

One more edge case that you need to fix, but feel free to land once you've done that. Thanks!

review: Approve
Revision history for this message
Kit Randel (blr) wrote :

Reply to inline comment.

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/mail/codereviewcomment.py'
2--- lib/lp/code/mail/codereviewcomment.py 2014-05-21 08:52:53 +0000
3+++ lib/lp/code/mail/codereviewcomment.py 2015-06-30 08:52:09 +0000
4@@ -10,7 +10,7 @@
5 'CodeReviewCommentMailer',
6 ]
7
8-
9+from bzrlib import patches
10 from zope.component import getUtility
11 from zope.security.proxy import removeSecurityProxy
12
13@@ -169,18 +169,92 @@
14 content, content_type=content_type, filename=filename)
15
16
17+def format_comment(comment):
18+ """Returns a list of correctly formatted comment(s)."""
19+ comment_lines = []
20+ if comment is not None:
21+ comment_lines.append('')
22+ comment_lines.extend(comment.splitlines())
23+ comment_lines.append('')
24+ return comment_lines
25+
26+
27 def build_inline_comments_section(comments, diff_text):
28- """Return a formatted text section with contextualized comments."""
29+ """Return a formatted text section with contextualized comments.
30+
31+ Hunks without comments are skipped to limit verbosity.
32+ Comments can be rendered after patch headers, hunk context lines,
33+ and hunk lines.
34+ """
35+ diff_lines = diff_text.splitlines(True)
36+
37+ diff_patches = patches.parse_patches(
38+ diff_lines, allow_dirty=True, keep_dirty=True)
39 result_lines = []
40- diff_lines = diff_text.splitlines()
41- for num, line in enumerate(diff_lines, 1):
42- result_lines.append(u'> {0}'.format(line.decode('utf-8', 'replace')))
43- comment = comments.get(str(num))
44- if comment is not None:
45- result_lines.append('')
46- result_lines.extend(comment.splitlines())
47- result_lines.append('')
48-
49- result_text = u'\n'.join(result_lines)
50-
51+ line_count = 0 # track lines in original diff
52+
53+ for patch in diff_patches:
54+ patch_lines = []
55+ dirty_head = []
56+ dirty_comment = False
57+ patch_comment = False
58+
59+ if isinstance(patch, dict) and 'dirty_head' in patch:
60+ for line in patch['dirty_head']:
61+ dirty_head.append(u'> %s' % line.rstrip('\n'))
62+ line_count += 1 # inc for dirty headers
63+ comment = comments.get(str(line_count))
64+ if comment:
65+ dirty_head.extend(format_comment(comment))
66+ dirty_comment = True
67+ patch = patch['patch']
68+
69+ for ph in patch.get_header().splitlines():
70+ line_count += 1 # inc patch headers
71+ comment = comments.get(str(line_count))
72+
73+ patch_lines.append('> {0}'.format(ph))
74+ if comment:
75+ patch_lines.extend(format_comment(comment))
76+ patch_comment = True
77+
78+ keep_hunks = [] # preserve hunks with comments
79+ for hunk in patch.hunks:
80+ hunk_lines = []
81+ hunk_comment = False
82+
83+ # add context line (hunk header)
84+ line_count += 1 # inc hunk context line
85+ hunk_lines.append(u'> %s' % hunk.get_header().rstrip('\n'))
86+
87+ # comment for context line (hunk header)
88+ comment = comments.get(str(line_count))
89+ if comment:
90+ hunk_lines.extend(format_comment(comment))
91+ hunk_comment = True
92+
93+ for line in hunk.lines:
94+ line_count += 1 # inc hunk lines
95+
96+ # line is a ContextLine/ReplaceLine
97+ hunk_lines.append(u'> %s' % str(line).rstrip('\n').decode(
98+ 'utf-8', 'replace'))
99+ comment = comments.get(str(line_count))
100+ if comment:
101+ hunk_lines.extend(format_comment(comment))
102+ hunk_comment = True
103+
104+ # preserve hunks for context if comment in patch header
105+ if patch_comment or hunk_comment:
106+ keep_hunks.extend(hunk_lines)
107+
108+ # Add entire patch and hunks to result if comment found
109+ if patch_comment or keep_hunks:
110+ result_lines.extend(dirty_head)
111+ result_lines.extend(patch_lines)
112+ result_lines.extend(keep_hunks)
113+ elif dirty_comment:
114+ result_lines.extend(dirty_head)
115+
116+ result_text = '\n'.join(result_lines)
117 return '\n\nDiff comments:\n\n%s\n\n' % result_text
118
119=== modified file 'lib/lp/code/mail/tests/test_codereviewcomment.py'
120--- lib/lp/code/mail/tests/test_codereviewcomment.py 2014-08-19 04:56:39 +0000
121+++ lib/lp/code/mail/tests/test_codereviewcomment.py 2015-06-30 08:52:09 +0000
122@@ -91,7 +91,7 @@
123 comment.branch_merge_proposal, mailer.merge_proposal)
124 sender = comment.message.owner
125 sender_address = format_address(sender.displayname,
126- sender.preferredemail.email)
127+ sender.preferredemail.email)
128 self.assertEqual(sender_address, mailer.from_address)
129 self.assertEqual(comment, mailer.code_review_comment)
130
131@@ -145,9 +145,10 @@
132 source_branch = mailer.merge_proposal.source_branch
133 branch_name = source_branch.bzr_identity
134 self.assertEqual(
135- ctrl.body.splitlines()[-3:], ['-- ',
136- canonical_url(mailer.merge_proposal),
137- 'You are subscribed to branch %s.' % branch_name])
138+ ctrl.body.splitlines()[-3:], [
139+ '-- ', canonical_url(mailer.merge_proposal),
140+ 'You are subscribed to branch %s.' % branch_name
141+ ])
142 rationale = mailer._recipients.getReason('subscriber@example.com')[1]
143 expected = {'X-Launchpad-Branch': source_branch.unique_name,
144 'X-Launchpad-Message-Rationale': rationale,
145@@ -216,7 +217,8 @@
146 inline_comments=None):
147 """Create a `CodeReviewComment` with inline (diff) comments."""
148 bmp = self.factory.makeBranchMergeProposal()
149- bmp.source_branch.subscribe(bmp.registrant,
150+ bmp.source_branch.subscribe(
151+ bmp.registrant,
152 BranchSubscriptionNotificationLevel.NOEMAIL, None,
153 CodeReviewNotificationLevel.FULL, bmp.registrant)
154 previewdiff = self.factory.makePreviewDiff(merge_proposal=bmp)
155@@ -239,7 +241,7 @@
156 See `build_inline_comments_section` tests for formatting details.
157 """
158 comment = self.makeCommentWithInlineComments(
159- inline_comments={'2': u'Is this from Planet Earth\xa9 ?'})
160+ inline_comments={'3': 'Is this from Pl\u0060net Earth ?'})
161 mailer = CodeReviewCommentMailer.forCreation(comment)
162 commenter = comment.branch_merge_proposal.registrant
163 ctrl = mailer.generateEmail(
164@@ -249,14 +251,14 @@
165 '',
166 'Diff comments:',
167 '',
168- "> === zbqvsvrq svyr 'yvo/yc/pbqr/vagresnprf/qvss.cl'",
169- '> --- yvo/yc/pbqr/vagresnprf/qvss.cl '
170- '2009-10-01 13:25:12 +0000',
171- '',
172- u'Is this from Planet Earth\xa9 ?',
173- '',
174- '> +++ yvo/yc/pbqr/vagresnprf/qvss.cl '
175- '2010-02-02 15:48:56 +0000'
176+ ("> === zbqvsvrq svyr 'yvo/yc/pbqr/vagresnprf/qvss.cl'"),
177+ ('> --- yvo/yc/pbqr/vagresnprf/qvss.cl '
178+ '2009-10-01 13:25:12 +0000'),
179+ ('> +++ yvo/yc/pbqr/vagresnprf/qvss.cl '
180+ '2010-02-02 15:48:56 +0000'),
181+ '',
182+ 'Is this from Pl\u0060net Earth ?',
183+ '',
184 ]
185 self.assertEqual(expected_lines, ctrl.body.splitlines()[1:10])
186
187@@ -317,7 +319,8 @@
188 bmp = self.factory.makeBranchMergeProposal(registrant=proposer)
189 commenter = self.factory.makePerson(
190 email='commenter@email.com', displayname='Commenter')
191- bmp.source_branch.subscribe(commenter,
192+ bmp.source_branch.subscribe(
193+ commenter,
194 BranchSubscriptionNotificationLevel.NOEMAIL, None,
195 CodeReviewNotificationLevel.FULL, commenter)
196 comment = bmp.createComment(commenter, 'hello')
197@@ -378,6 +381,8 @@
198 """Tests for `build_inline_comments_section`."""
199
200 diff_text = (
201+ "=== added directory 'foo/bar'\n"
202+ "=== modified file 'foo/bar/baz.py'\n"
203 "--- bar\t2009-08-26 15:53:34.000000000 -0400\n"
204 "+++ bar\t1969-12-31 19:00:00.000000000 -0500\n"
205 "@@ -1,3 +0,0 @@\n"
206@@ -389,6 +394,9 @@
207 "@@ -0,0 +1,2 @@\n"
208 "+a\n"
209 "+b\n"
210+ "@@ -1,2 +0,0 @@\n"
211+ "-x\n"
212+ "-y\n"
213 "--- foo\t2009-08-26 15:53:23.000000000 -0400\n"
214 "+++ foo\t2009-08-26 15:56:43.000000000 -0400\n"
215 "@@ -1,3 +1,4 @@\n"
216@@ -408,48 +416,119 @@
217 # The inline comments section starts with a 4-lines header
218 # (empty lines and title) and ends with an empty line.
219 section = self.getSection({}).splitlines()
220- header = section[:5]
221+ header = section[:4]
222 self.assertEqual(
223 ['',
224 '',
225 'Diff comments:',
226- '',
227- '> --- bar\t2009-08-26 15:53:34.000000000 -0400'],
228- header)
229- footer = section[-2:]
230+ ''], header)
231+ footer = section[-1:]
232 self.assertEqual(
233- ['> +e',
234- ''],
235+ [''],
236 footer)
237
238 def test_single_line_comment(self):
239 # The inline comments are correctly contextualized in the diff.
240 # and prefixed with '>>> '
241- comments = {'2': u'\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae'}
242- self.assertEqual(
243- ['> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
244- '',
245- u'\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae',
246- '',
247- '> @@ -1,3 +0,0 @@',
248- u'> -\xe5'],
249- self.getSection(comments).splitlines()[5:11])
250+ comments = {'4': '\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae'}
251+ self.assertEqual(
252+ map(unicode, [
253+ '> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
254+ '',
255+ '\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae',
256+ '']),
257+ self.getSection(comments).splitlines()[7:11])
258+
259+ def test_commentless_hunks_ignored(self):
260+ # Hunks without inline comments are not returned in the diff text.
261+ comments = {'16': 'A comment', '21': 'Another comment'}
262+ self.assertEqual(
263+ map(unicode, [
264+ '> --- baz\t1969-12-31 19:00:00.000000000 -0500',
265+ '> +++ baz\t2009-08-26 15:53:57.000000000 -0400',
266+ '> @@ -1,2 +0,0 @@',
267+ '> -x',
268+ '> -y',
269+ '',
270+ 'A comment',
271+ '',
272+ '> --- foo\t2009-08-26 15:53:23.000000000 -0400',
273+ '> +++ foo\t2009-08-26 15:56:43.000000000 -0400',
274+ '> @@ -1,3 +1,4 @@',
275+ '> a',
276+ '> -b',
277+ '',
278+ 'Another comment',
279+ '',
280+ '> c',
281+ '> +d',
282+ '> +e']),
283+ self.getSection(comments).splitlines()[4:23])
284+
285+ def test_patch_header_comment(self):
286+ # Inline comments in patch headers are rendered correctly and
287+ # include the patch's hunk(s).
288+ comments = {'17': 'A comment in the patch header', '18': 'aardvark'}
289+ self.assertEqual(
290+ map(unicode, [
291+ '> --- foo\t2009-08-26 15:53:23.000000000 -0400',
292+ '',
293+ 'A comment in the patch header',
294+ '',
295+ '> +++ foo\t2009-08-26 15:56:43.000000000 -0400',
296+ '',
297+ 'aardvark',
298+ '',
299+ '> @@ -1,3 +1,4 @@',
300+ '> a',
301+ '> -b',
302+ '> c',
303+ '> +d',
304+ '> +e']),
305+ self.getSection(comments).splitlines()[4:18])
306+
307+ def test_dirty_header_comment(self):
308+ # Inline comments in dirty headers e.g. 'added file/modified file'
309+ # are rendered correctly
310+ comments = {'1': 'A comment for a dirty header'}
311+ self.assertEqual(
312+ map(unicode, [
313+ "> === added directory 'foo/bar'",
314+ '',
315+ 'A comment for a dirty header',
316+ '']),
317+ self.getSection(comments).splitlines()[4:8])
318+
319+ def test_non_last_hunk_comment(self):
320+ comments = {'12': 'A comment in the non-last hunk'}
321+ self.assertEqual(
322+ map(unicode, [
323+ '> --- baz\t1969-12-31 19:00:00.000000000 -0500',
324+ '> +++ baz\t2009-08-26 15:53:57.000000000 -0400',
325+ '> @@ -0,0 +1,2 @@',
326+ '> +a',
327+ '',
328+ 'A comment in the non-last hunk',
329+ '',
330+ '> +b']),
331+ self.getSection(comments).splitlines()[4:12])
332
333 def test_multi_line_comment(self):
334 # Inline comments with multiple lines are rendered appropriately.
335- comments = {'2': 'Foo\nBar'}
336+ comments = {'4': 'Foo\nBar'}
337 self.assertEqual(
338- ['> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
339- '',
340- 'Foo',
341- 'Bar',
342- '',
343- '> @@ -1,3 +0,0 @@'],
344- self.getSection(comments).splitlines()[5:11])
345+ map(unicode, [
346+ '> --- bar\t2009-08-26 15:53:34.000000000 -0400',
347+ '> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
348+ '',
349+ 'Foo',
350+ 'Bar',
351+ '']),
352+ self.getSection(comments).splitlines()[6:12])
353
354 def test_multiple_comments(self):
355 # Multiple inline comments are redered appropriately.
356- comments = {'2': 'Foo', '3': 'Bar'}
357+ comments = {'4': 'Foo', '5': 'Bar'}
358 self.assertEqual(
359 ['> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
360 '',
361@@ -459,4 +538,4 @@
362 '',
363 'Bar',
364 ''],
365- self.getSection(comments).splitlines()[5:13])
366+ self.getSection(comments).splitlines()[7:15])
367
368=== modified file 'versions.cfg'
369--- versions.cfg 2015-06-24 07:45:11 +0000
370+++ versions.cfg 2015-06-30 08:52:09 +0000
371@@ -16,7 +16,7 @@
372 auditorfixture = 0.0.5
373 BeautifulSoup = 3.2.1
374 bson = 0.3.3
375-bzr = 2.6.0
376+bzr = 2.6.0.lp.1
377 celery = 2.5.1
378 Chameleon = 2.11
379 cssselect = 0.9.1