Merge lp:~wallyworld/launchpad/improved-broken-link-handling into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Ian Booth
Approved revision: no longer in the source branch.
Merged at revision: 11806
Proposed branch: lp:~wallyworld/launchpad/improved-broken-link-handling
Merge into: lp:launchpad
Diff against target: 570 lines (+415/-28)
12 files modified
lib/canonical/launchpad/icing/style-3-0.css.in (+6/-0)
lib/lp/app/browser/configure.zcml (+6/-0)
lib/lp/app/browser/linkchecker.py (+77/-0)
lib/lp/app/browser/stringformatter.py (+3/-1)
lib/lp/app/browser/tests/test_linkchecker.py (+83/-0)
lib/lp/app/configure.zcml (+0/-14)
lib/lp/app/doc/displaying-paragraphs-of-text.txt (+11/-11)
lib/lp/app/javascript/lp-links.js (+105/-0)
lib/lp/app/templates/base-layout-macros.pt (+9/-0)
lib/lp/bugs/windmill/tests/test_bug_commenting.py (+1/-1)
lib/lp/code/windmill/tests/test_branch_broken_links.py (+113/-0)
lib/lp/code/windmill/tests/test_branchmergeproposal_review.py (+1/-1)
To merge this branch: bzr merge lp:~wallyworld/launchpad/improved-broken-link-handling
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Launchpad code reviewers js Pending
Review via email: mp+37095@code.launchpad.net

Commit message

Add invalid-link style to invalid lp branch short links and prevent click through

Description of the change

Summary
------------
Render lp: shortcuts such that any invalid ones are shown as grey and their onclick() is suppressed.
This branch supports processing +branch links.

Implementation detail
----------------------------
When the links are harvested via the linkify_substitution() api, they are assigned a class "short-branch-link". AN onload method harvests such links and makes an ajax call to have them validated. Any invalid links have their class changed to "invalid-link" and the css styling takes care of the final rendering.

Tests
------

New tests:
lp/app/browser/tests/test_linkchecker.py
lp/code/windmill/tests/test_branch_broken_links.py

The windmill test is broken because windmill has an issue making the ajax call. Manual testing shows the functionality works as expected.

Lint
----

Linting changed files:
  lib/canonical/launchpad/icing/style-3-0.css.in
  lib/lp/app/configure.zcml
  lib/lp/app/browser/configure.zcml
  lib/lp/app/browser/linkchecker.py
  lib/lp/app/browser/stringformatter.py
  lib/lp/app/browser/tests/test_linkchecker.py
  lib/lp/app/doc/displaying-paragraphs-of-text.txt
  lib/lp/app/javascript/lp-links.js
  lib/lp/app/templates/base-layout-macros.pt
  lib/lp/code/windmill/tests/test_branch_broken_links.py

./lib/canonical/launchpad/icing/style-3-0.css.in
     144: Line exceeds 78 characters.
    1324: Line exceeds 78 characters.
    1941: Line exceeds 78 characters.
    1957: Line exceeds 78 characters.
    1961: Line exceeds 78 characters.
    1969: Line exceeds 78 characters.
    1973: Line exceeds 78 characters.
    2001: Line exceeds 78 characters.
    2017: Line exceeds 78 characters.
    2025: Line exceeds 78 characters.
    2029: Line exceeds 78 characters.
    2033: Line exceeds 78 characters.
    2037: Line exceeds 78 characters.
    2041: Line exceeds 78 characters.
    2045: Line exceeds 78 characters.
    2058: Line exceeds 78 characters.
    2062: Line exceeds 78 characters.
    2094: Line exceeds 78 characters.
    2106: Line exceeds 78 characters.
    2110: Line exceeds 78 characters.
    2114: Line exceeds 78 characters.
    2122: Line exceeds 78 characters.
    2126: Line exceeds 78 characters.
    2134: Line exceeds 78 characters.
    2162: Line exceeds 78 characters.
    2166: Line exceeds 78 characters.
    2194: Line exceeds 78 characters.
    2202: Line exceeds 78 characters.
    2207: Line exceeds 78 characters.
    2211: Line exceeds 78 characters.
    2216: Line exceeds 78 characters.
    2220: Line exceeds 78 characters.
    2224: Line exceeds 78 characters.
    2228: Line exceeds 78 characters.
    2232: Line exceeds 78 characters.
    2284: Line exceeds 78 characters.
    2292: Line exceeds 78 characters.
    2296: Line exceeds 78 characters.
    2304: Line exceeds 78 characters.
    2316: Line exceeds 78 characters.
    2324: Line exceeds 78 characters.
    2328: Line exceeds 78 characters.
    2332: Line exceeds 78 characters.
    2340: Line exceeds 78 characters.
    2344: Line exceeds 78 characters.
    2348: Line exceeds 78 characters.
    2352: Line exceeds 78 characters.
    2356: Line exceeds 78 characters.
    2365: Line exceeds 78 characters.
    2373: Line exceeds 78 characters.
    2505: Line exceeds 78 characters.
    2506: Line exceeds 78 characters.
    2575: Line exceeds 78 characters.
    2576: Line exceeds 78 characters.
./lib/lp/app/browser/stringformatter.py
     409: E501 line too long (90 characters)
     413: E501 line too long (88 characters)
     409: Line exceeds 78 characters.
     413: Line exceeds 78 characters.
./lib/lp/app/doc/displaying-paragraphs-of-text.txt
       1: narrative uses a moin header.
       7: narrative uses a moin header.
      32: want exceeds 78 characters.
      33: want exceeds 78 characters.
      43: source exceeds 78 characters.
      49: want exceeds 78 characters.
      83: want exceeds 78 characters.
      84: want exceeds 78 characters.
      86: want exceeds 78 characters.
      87: want exceeds 78 characters.
     102: want exceeds 78 characters.
     103: want exceeds 78 characters.
     104: want exceeds 78 characters.
     105: want exceeds 78 characters.
     106: want exceeds 78 characters.
     107: want exceeds 78 characters.
     110: narrative uses a moin header.
     138: source exceeds 78 characters.
     155: want exceeds 78 characters.
     156: want exceeds 78 characters.
     157: want exceeds 78 characters.
     158: want exceeds 78 characters.
     159: want exceeds 78 characters.
     160: want exceeds 78 characters.
     161: want exceeds 78 characters.
     162: want exceeds 78 characters.
     163: want exceeds 78 characters.
     164: want exceeds 78 characters.
     165: want exceeds 78 characters.
     166: want exceeds 78 characters.
     167: want exceeds 78 characters.
     168: want exceeds 78 characters.
     169: want exceeds 78 characters.
     170: want exceeds 78 characters.
     171: want exceeds 78 characters.
     172: want exceeds 78 characters.
     173: want exceeds 78 characters.
     174: want exceeds 78 characters.
     175: want exceeds 78 characters.
     176: want exceeds 78 characters.
     177: want exceeds 78 characters.
     178: want exceeds 78 characters.
     179: want exceeds 78 characters.
     180: want exceeds 78 characters.
     181: want exceeds 78 characters.
     182: want exceeds 78 characters.
     183: want exceeds 78 characters.
     184: want exceeds 78 characters.
     185: want exceeds 78 characters.
     186: want exceeds 78 characters.
     187: want exceeds 78 characters.
     188: want exceeds 78 characters.
     189: want exceeds 78 characters.
     190: want exceeds 78 characters.
     191: want exceeds 78 characters.
     204: narrative uses a moin header.
     314: narrative uses a moin header.
     332: want exceeds 78 characters.
     343: narrative uses a moin header.
     361: want exceeds 78 characters.
     363: want exceeds 78 characters.
     387: narrative uses a moin header.
     424: want exceeds 78 characters.
     430: want exceeds 78 characters.
     434: want exceeds 78 characters.
     438: want exceeds 78 characters.
     442: want exceeds 78 characters.
     472: narrative uses a moin header.
     487: narrative exceeds 78 characters.
./lib/lp/code/windmill/tests/test_branch_broken_links.py
      90: E501 line too long (102 characters)
      96: E501 line too long (96 characters)
     123: E302 expected 2 blank lines, found 3
      90: Line exceeds 78 characters.
      96: Line exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Just a note, not a review: why so much 2009 and so little 2010 in the copyright notices? :-)

Revision history for this message
Ian Booth (wallyworld) wrote :

There's only one real instance, due to a cut'n'paste :-) The other one
was a file that was deleted and then reverted and bzr has treated it as
a delete and then an add, so it is a false positive.

On 21/10/10 19:47, Jeroen T. Vermeulen wrote:
> Just a note, not a review: why so much 2009 and so little 2010 in the copyright notices? :-)

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (4.8 KiB)

This is a nice piece of functionality. I have a lot of comments but
most if not all of them are trivial or minor. I'm going to ask someone
else who has more up-to-date experience with Javascript in Launchpad
to give this a look too.

[1]

+ <browser:page
+ for="*"
+ name="+check-links"
+ class="lp.app.browser.linkchecker.LinkCheckerAPI"
+ permission="zope.Public"
+ />

Although the indentation is inconsistent in that file, and although it
doesn't matter very much, can you make this consistent with what's
already there?

[2]

+from zope.component._api import getUtility

getUtility is normally imported directly from zope.component.

[3]

+from zope.component._api import getUtility
+
+from lp.code.interfaces.branchlookup import IBranchLookup
+
+__metaclass__ = type
+
+__all__ = [
+ 'LinkCheckerAPI',
+ ]
+
+import simplejson

I think __metaclass__ and __all__ normally appear first in Launchpad
source, followed by imports. I think utilities/format-imports expects
that too (and you should also run this on your changed files, or use
utilities/format-new-and-modified-imports -r submit: as a convenience).

[4]

+ """ Validates Launchpad shortcut links.

s/" V/"V/

[5]

+ result['invalid_'+link_type]=invalid_links

s/[+=]/ \1 /

[6]

+ path = link.strip('/')[len('+branch/'):]

Is it possible that the branch link does not begin with +branch? I'm
not used to seeing branches with +branch in their URLs.

[7]

+ try:
+ branch_lookup.getByLPPath(path)
+ except:
+ invalid_links.append(link)

Don't use default except clauses like this. Catch Exception if
absolutely necessary, or, preferably, catch just those exceptions that
could be raised.

[8]

+ return '<a href="%s" class="%s">%s</a>%s' % (
                 cgi.escape(url, quote=True),
+ "branch-short-link",

Just chuck "branch-short-link" into the format string rather than
interpolate.

[9]

+ self.assertEqual(len(invalid_links), len(links_to_check))
+ self.assertEqual(set(invalid_links), set(links_to_check))

Only the second test is needed. In fact, if the first test fails the
error message will be less informative than if the second fails (which
it will if the first fails).

[10]

+ def invoke_branch_link_checker(
+ self, valid_branch_urls=dict(), invalid_branch_urls=dict()):

Default arguments must not be mutable. Use None as a default then
check for that in the method.

[11]

Is it likely that we hit any query length problems when requesting the
status of a page with many links?

[12]

+ // Get any links of the specified link_class and put store them as the
+ // specified link_type in the specified links_holder

s/put store/store/

[13]

+function harvest_links(Y, links_holder, link_class, link_type) {

I think this function would be cleaner if it returned the link_info
array instead of putting it into links_holder. That way it need only
accept two arguments, Y and link_class. Callers should check if the
returned array is empty.

[14]

Also, given [13] and the presence of the YUI collection module,
harvest_links() could be much shorter ...

Read more...

review: Needs Fixing
Revision history for this message
Ian Booth (wallyworld) wrote :
Download full text (4.6 KiB)

Hi

Thanks for the most excellent review. It's the most thorough one I've
had. I've addressed most of the issues except for the one item - see my
comments. The import issues were due to the "curse" of having an IDE
that auto imports missing references and it guessed wrong. A small price
to pay given all the other great stuff it does.

>
> I think __metaclass__ and __all__ normally appear first in Launchpad
> source, followed by imports. I think utilities/format-imports expects
> that too (and you should also run this on your changed files, or use
> utilities/format-new-and-modified-imports -r submit: as a convenience).
>

Thanks for the tip - I had no idea those scripts existed.

>
> [6]
>
> + path = link.strip('/')[len('+branch/'):]
>
> Is it possible that the branch link does not begin with +branch? I'm
> not used to seeing branches with +branch in their URLs.
>

No, all branch links processed by this new functionality begin with
+branch. See line 276 of stringformatter.py:

    url = '/+branch/%s' % path

>
> [11]
>
> Is it likely that we hit any query length problems when requesting the
> status of a page with many links?
>

I don't think so? A post operation is used so I thought that would have
it covered?

>
> [13]
>
> +function harvest_links(Y, links_holder, link_class, link_type) {
>
> I think this function would be cleaner if it returned the link_info
> array instead of putting it into links_holder. That way it need only
> accept two arguments, Y and link_class. Callers should check if the
> returned array is empty.
>

The code has been written so support processing for than just one type
of link. At the moment, there's only branch links. But there will also
be bugs links etc. So the code will look like:

var links_to_check = {}

harvest_links(Y, links_to_check, 'branch-short-link', 'branch_links');
harvest_links(Y, links_to_check, 'bug-link', 'bugs_links');
etc

So we are building a dict mapping link_type_name->[hrefs]

The review comment would be valid if we only ever were going to process
the one type of link. I think the implementation as written is ok given
the above explanation?

>
> [14]
>
> Also, given [13] and the presence of the YUI collection module,
> harvest_links() could be much shorter and probably quicker:
>
> return Y.Array.unique(
> Y.all('.' + link_class).get('href'));
>
> To get the collection module you'll need to add the following in the
> right place (I added it at line 108) in base-layout-macros.pt:
>
> <script type="text/javascript"
> tal:attributes="src string:${yui}/collection/collection.js"></script>
>

I think Array.unique() is only available in YUI 3.2 and at the time of
writing we were not using that version yet. I originally tried to use
Array.unique() only to be disappointed :-(

>
> [15]
>
> + alert(msg);
>
> Really? Just checking :)

I wanted to give the user feedback why the "link" they just clicked on
didn't seem to work instead of just swallowing the onclick().

> [16]
>
> In TestBranchBugLinks (which should probably be renamed), instead of
> creating a new question via the browser, consider creating a new
> question...

Read more...

Revision history for this message
Gavin Panella (allenap) wrote :

> Thanks for the most excellent review. It's the most thorough one
> I've had.

I hope it was useful...

I often do quite thorough reviews and I worry that I'm being too
thorough. Let me know if you ever feel that my reviews are
demotivating or too picky. That would mean I'm doing it wrong :)

[14]

> I think Array.unique() is only available in YUI 3.2 and at the time
> of writing we were not using that version yet. I originally tried to
> use Array.unique() only to be disappointed :-(

Okay. Fwiw, this should work now in Launchpad, though it's confusing
because Array.unique() is a static function (or whatever YUI calls
it), and is not available on instances of Array.

[19]

> *news flash* - problem "solved" wtf. By changing the test to
> creating a bug instead of a question, it works in windmill. Go
> figure. But it works now \o/

Hurrah! And weird. But mostly hurrah :)

review: Approve
Revision history for this message
Ian Booth (wallyworld) wrote :

On 22/10/10 19:11, Gavin Panella wrote:
> Review: Approve
>> Thanks for the most excellent review. It's the most thorough one
>> I've had.
>
> I hope it was useful...
>

Extremely. Thanks. Especially since I'm a new to lp and have figured
stuff out mostly by trial and error or by looking at what others have
done or reverse engineering etc.

> I often do quite thorough reviews and I worry that I'm being too
> thorough. Let me know if you ever feel that my reviews are
> demotivating or too picky. That would mean I'm doing it wrong :)
>
>

On the contrary, I see code reviews as a great learning opportunity,
regardless of how experienced the code author may or may not be. A 2nd
set of eyes always picks up issues that the author is often too close to
the code to see. Plus they are a great way to help share knowledge
across team boundaries.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/icing/style-3-0.css.in'
2--- lib/canonical/launchpad/icing/style-3-0.css.in 2010-09-23 11:17:45 +0000
3+++ lib/canonical/launchpad/icing/style-3-0.css.in 2010-10-27 04:31:25 +0000
4@@ -284,6 +284,12 @@
5 a.help.icon, a.sprite.maybe.help {
6 border: none;
7 }
8+a.invalid-link {
9+ disabled: True;
10+ color: #909090;
11+ text-decoration: none;
12+ cursor: default;
13+ }
14 img, a img {
15 /* No border on images that are links. */
16 border: none;
17
18=== modified file 'lib/lp/app/browser/configure.zcml'
19--- lib/lp/app/browser/configure.zcml 2010-10-15 01:27:04 +0000
20+++ lib/lp/app/browser/configure.zcml 2010-10-27 04:31:25 +0000
21@@ -98,6 +98,12 @@
22 template="../templates/launchpad-search-form.pt"
23 permission="zope.Public" />
24
25+ <browser:page
26+ for="*"
27+ name="+check-links"
28+ class="lp.app.browser.linkchecker.LinkCheckerAPI"
29+ permission="zope.Public"/>
30+
31 <!-- TALES namespaces. -->
32
33 <!-- TALES lp: namespace (should be deprecated) -->
34
35=== added file 'lib/lp/app/browser/linkchecker.py'
36--- lib/lp/app/browser/linkchecker.py 1970-01-01 00:00:00 +0000
37+++ lib/lp/app/browser/linkchecker.py 2010-10-27 04:31:25 +0000
38@@ -0,0 +1,77 @@
39+# Copyright 2009 Canonical Ltd. This software is licensed under the
40+# GNU Affero General Public License version 3 (see the file LICENSE).
41+
42+# pylint: disable-msg=E0211,E0213
43+
44+__metaclass__ = type
45+__all__ = [
46+ 'LinkCheckerAPI',
47+ ]
48+
49+import simplejson
50+from zope.component import getUtility
51+
52+from lp.app.errors import NotFoundError
53+from lp.code.errors import (
54+ CannotHaveLinkedBranch,
55+ InvalidNamespace,
56+ NoLinkedBranch,
57+ NoSuchBranch,
58+ )
59+from lp.code.interfaces.branchlookup import IBranchLookup
60+from lp.registry.interfaces.product import InvalidProductName
61+
62+
63+class LinkCheckerAPI:
64+ """Validates Launchpad shortcut links.
65+
66+ This class provides the endpoint of an Ajax call to .../+check-links.
67+ When invoked with a collection of links harvested from a page, it will
68+ check the validity of each one and send a response containing those that
69+ are invalid. Javascript on the page will set the style of invalid links to
70+ something appropriate.
71+
72+ This initial implementation supports processing links like the following:
73+ /+branch/foo/bar
74+
75+ The implementation can easily be extended to handle other forms by
76+ providing a method to handle the link type extracted from the json
77+ request.
78+ """
79+
80+ def __init__(self, context, request):
81+ # We currently only use the request.
82+ # self.context = context
83+ self.request = request
84+
85+ # Each link type has it's own validation method.
86+ self.link_checkers = dict(
87+ branch_links=self.check_branch_links,
88+ )
89+
90+ def __call__(self):
91+ result = {}
92+ links_to_check_data = self.request.get('link_hrefs')
93+ links_to_check = simplejson.loads(links_to_check_data)
94+
95+ for link_type in links_to_check:
96+ links = links_to_check[link_type]
97+ invalid_links = self.link_checkers[link_type](links)
98+ result['invalid_'+link_type] = invalid_links
99+
100+ self.request.response.setHeader('Content-type', 'application/json')
101+ return simplejson.dumps(result)
102+
103+ def check_branch_links(self, links):
104+ """Check links of the form /+branch/foo/bar"""
105+ invalid_links = []
106+ branch_lookup = getUtility(IBranchLookup)
107+ for link in links:
108+ path = link.strip('/')[len('+branch/'):]
109+ try:
110+ branch_lookup.getByLPPath(path)
111+ except (CannotHaveLinkedBranch, InvalidNamespace,
112+ InvalidProductName, NoLinkedBranch, NoSuchBranch,
113+ NotFoundError):
114+ invalid_links.append(link)
115+ return invalid_links
116
117=== modified file 'lib/lp/app/browser/stringformatter.py'
118--- lib/lp/app/browser/stringformatter.py 2010-09-25 14:29:32 +0000
119+++ lib/lp/app/browser/stringformatter.py 2010-10-27 04:31:25 +0000
120@@ -274,7 +274,9 @@
121 return FormattersAPI._linkify_bug_number(
122 lp_url, path, trailers)
123 url = '/+branch/%s' % path
124- return '<a href="%s">%s</a>%s' % (
125+ # Mark the links with a 'branch-short-link' class so they can be
126+ # harvested and validated when the page is rendered.
127+ return '<a href="%s" class="branch-short-link">%s</a>%s' % (
128 cgi.escape(url, quote=True),
129 cgi.escape(lp_url),
130 cgi.escape(trailers))
131
132=== added file 'lib/lp/app/browser/tests/test_linkchecker.py'
133--- lib/lp/app/browser/tests/test_linkchecker.py 1970-01-01 00:00:00 +0000
134+++ lib/lp/app/browser/tests/test_linkchecker.py 2010-10-27 04:31:25 +0000
135@@ -0,0 +1,83 @@
136+# Copyright 2010 Canonical Ltd. This software is licensed under the
137+# GNU Affero General Public License version 3 (see the file LICENSE).
138+
139+"""Unit tests for the LinkCheckerAPI."""
140+
141+__metaclass__ = type
142+
143+from random import shuffle
144+
145+import simplejson
146+from zope.security.proxy import removeSecurityProxy
147+
148+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
149+from canonical.testing.layers import DatabaseFunctionalLayer
150+from lp.app.browser.linkchecker import LinkCheckerAPI
151+from lp.testing import TestCaseWithFactory
152+
153+
154+class TestLinkCheckerAPI(TestCaseWithFactory):
155+
156+ layer = DatabaseFunctionalLayer
157+
158+ BRANCH_URL_TEMPLATE = '/+branch/%s'
159+
160+ def check_invalid_links(self, result_json, link_type, invalid_links):
161+ link_dict = simplejson.loads(result_json)
162+ links_to_check = link_dict[link_type]
163+ self.assertEqual(len(invalid_links), len(links_to_check))
164+ self.assertEqual(set(invalid_links), set(links_to_check))
165+
166+ def make_valid_links(self):
167+ branch = self.factory.makeProductBranch()
168+ valid_branch_url = self.BRANCH_URL_TEMPLATE % branch.unique_name
169+ product = self.factory.makeProduct()
170+ product_branch = self.factory.makeProductBranch(product=product)
171+ removeSecurityProxy(product).development_focus.branch = product_branch
172+ valid_product_url = self.BRANCH_URL_TEMPLATE % product.name
173+
174+ return [
175+ valid_branch_url,
176+ valid_product_url,
177+ ]
178+
179+ def make_invalid_links(self):
180+ return [
181+ self.BRANCH_URL_TEMPLATE % 'foo',
182+ self.BRANCH_URL_TEMPLATE % 'bar',
183+ ]
184+
185+ def invoke_branch_link_checker(
186+ self, valid_branch_urls=None, invalid_branch_urls=None):
187+ if valid_branch_urls is None:
188+ valid_branch_urls = {}
189+ if invalid_branch_urls is None:
190+ invalid_branch_urls = {}
191+
192+ branch_urls = list(valid_branch_urls)
193+ branch_urls.extend(invalid_branch_urls)
194+ shuffle(branch_urls)
195+
196+ links_to_check = dict(branch_links=branch_urls)
197+ link_json = simplejson.dumps(links_to_check)
198+
199+ request = LaunchpadTestRequest(link_hrefs=link_json)
200+ link_checker = LinkCheckerAPI(object(), request)
201+ result_json = link_checker()
202+ self.check_invalid_links(
203+ result_json, 'invalid_branch_links', invalid_branch_urls)
204+
205+ def test_only_valid_branchlinks(self):
206+ branch_urls = self.make_valid_links()
207+ self.invoke_branch_link_checker(valid_branch_urls=branch_urls)
208+
209+ def test_only_invalid_branchlinks(self):
210+ branch_urls = self.make_invalid_links()
211+ self.invoke_branch_link_checker(invalid_branch_urls=branch_urls)
212+
213+ def test_valid_and_invald_branchlinks(self):
214+ valid_branch_urls = self.make_valid_links()
215+ invalid_branch_urls = self.make_invalid_links()
216+ self.invoke_branch_link_checker(
217+ valid_branch_urls=valid_branch_urls,
218+ invalid_branch_urls=invalid_branch_urls)
219
220=== added file 'lib/lp/app/configure.zcml'
221--- lib/lp/app/configure.zcml 1970-01-01 00:00:00 +0000
222+++ lib/lp/app/configure.zcml 2010-10-27 04:31:25 +0000
223@@ -0,0 +1,14 @@
224+<!-- Copyright 2009 Canonical Ltd. This software is licensed under the
225+ GNU Affero General Public License version 3 (see the file LICENSE).
226+-->
227+
228+<configure
229+ xmlns="http://namespaces.zope.org/zope"
230+ xmlns:browser="http://namespaces.zope.org/browser"
231+ xmlns:i18n="http://namespaces.zope.org/i18n"
232+ xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc"
233+ xmlns:lp="http://namespaces.canonical.com/lp"
234+ i18n_domain="launchpad">
235+ <include
236+ package=".browser"/>
237+</configure>
238
239=== removed file 'lib/lp/app/configure.zcml'
240--- lib/lp/app/configure.zcml 2009-07-17 02:25:09 +0000
241+++ lib/lp/app/configure.zcml 1970-01-01 00:00:00 +0000
242@@ -1,14 +0,0 @@
243-<!-- Copyright 2009 Canonical Ltd. This software is licensed under the
244- GNU Affero General Public License version 3 (see the file LICENSE).
245--->
246-
247-<configure
248- xmlns="http://namespaces.zope.org/zope"
249- xmlns:browser="http://namespaces.zope.org/browser"
250- xmlns:i18n="http://namespaces.zope.org/i18n"
251- xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc"
252- xmlns:lp="http://namespaces.canonical.com/lp"
253- i18n_domain="launchpad">
254- <include
255- package=".browser"/>
256-</configure>
257
258=== modified file 'lib/lp/app/doc/displaying-paragraphs-of-text.txt'
259--- lib/lp/app/doc/displaying-paragraphs-of-text.txt 2010-10-09 16:36:22 +0000
260+++ lib/lp/app/doc/displaying-paragraphs-of-text.txt 2010-10-27 04:31:25 +0000
261@@ -357,17 +357,17 @@
262 ... 'lp:///foo\n'
263 ... 'lp:/foo\n')
264 >>> print test_tales('foo/fmt:text-to-html', foo=text)
265- <p><a href="/+branch/~foo/bar/baz">lp:~foo/bar/baz</a><br />
266- <a href="/+branch/~foo/bar/bug-123">lp:~foo/bar/bug-123</a><br />
267- <a href="/+branch/~foo/+junk/baz">lp:~foo/+junk/baz</a><br />
268- <a href="/+branch/~foo/ubuntu/jaunty/evolution/baz">lp:~foo/ubuntu/jaunty/evolution/baz</a><br />
269- <a href="/+branch/foo/bar">lp:foo/bar</a><br />
270- <a href="/+branch/foo">lp:foo</a><br />
271- <a href="/+branch/foo">lp:foo</a>,<br />
272- <a href="/+branch/foo/bar">lp:foo/bar</a>.<br />
273- <a href="/+branch/foo/bar/baz">lp:foo/bar/baz</a><br />
274- <a href="/+branch/foo">lp:///foo</a><br />
275- <a href="/+branch/foo">lp:/foo</a></p>
276+ <p><a href="/+branch/~foo/bar/baz" class="...">lp:~foo/bar/baz</a><br />
277+ <a href="/+branch/~foo/bar/bug-123" class="...">lp:~foo/bar/bug-123</a><br />
278+ <a href="/+branch/~foo/+junk/baz" class="...">lp:~foo/+junk/baz</a><br />
279+ <a href="/+branch/~foo/ubuntu/jaunty/evolution/baz" class="...">lp:~foo/ubuntu/jaunty/evolution/baz</a><br />
280+ <a href="/+branch/foo/bar" class="...">lp:foo/bar</a><br />
281+ <a href="/+branch/foo" class="...">lp:foo</a><br />
282+ <a href="/+branch/foo" class="...">lp:foo</a>,<br />
283+ <a href="/+branch/foo/bar" class="...">lp:foo/bar</a>.<br />
284+ <a href="/+branch/foo/bar/baz" class="...">lp:foo/bar/baz</a><br />
285+ <a href="/+branch/foo" class="...">lp:///foo</a><br />
286+ <a href="/+branch/foo" class="...">lp:/foo</a></p>
287
288 Text that looks like a branch reference, but is followed only by digits is
289 treated as a link to a bug.
290
291=== added file 'lib/lp/app/javascript/lp-links.js'
292--- lib/lp/app/javascript/lp-links.js 1970-01-01 00:00:00 +0000
293+++ lib/lp/app/javascript/lp-links.js 2010-10-27 04:31:25 +0000
294@@ -0,0 +1,105 @@
295+/**
296+ * Launchpad utilities for manipulating links.
297+ *
298+ * @module app
299+ * @submodule links
300+ */
301+
302+YUI.add('lp.app.links', function(Y) {
303+
304+ function harvest_links(Y, links_holder, link_class, link_type) {
305+ // Get any links of the specified link_class and store them as the
306+ // specified link_type in the specified links_holder
307+ var link_info = new Array();
308+ Y.all('.'+link_class).each(function(link) {
309+ var href = link.getAttribute('href');
310+ if( link_info.indexOf(href)<0 ) {
311+ link_info.push(href);
312+ }
313+ });
314+ if( link_info.length > 0 ) {
315+ links_holder[link_type] = link_info;
316+ }
317+ }
318+
319+ function process_invalid_links(
320+ Y, link_info, link_class, link_type, title) {
321+ // We have a collection of invalid links possibly containing links of
322+ // type link_type, so we need to remove the existing link_class,
323+ // replace it with an invalid-link class, and set the link title.
324+ var invalid_links = Y.Array(link_info['invalid_'+link_type]);
325+
326+ if( invalid_links.length > 0) {
327+ Y.all('.'+link_class).each(function(link) {
328+ var href = link.getAttribute('href');
329+ if( invalid_links.indexOf(href)>=0 ) {
330+ var msg = title + href;
331+ link.removeClass(link_class);
332+ link.addClass('invalid-link');
333+ link.title = msg
334+ link.on('click', function(e) {
335+ e.halt();
336+ alert(msg);
337+ });
338+ }
339+ });
340+ }
341+ }
342+
343+ var links = Y.namespace('lp.app.links');
344+
345+ links.check_valid_lp_links = function() {
346+ // Grabs any lp: style links on the page and checks that they are
347+ // valid. Invalid ones have their class changed to "invalid-link".
348+ // ATM, we only handle +branch links.
349+
350+ var links_to_check = {}
351+
352+ // We get all the links with defined css classes.
353+ // At the moment, we just handle branch links, but in future...
354+ harvest_links(Y, links_to_check, 'branch-short-link', 'branch_links');
355+
356+ // Do we have anything to do?
357+ if( Y.Object.size(links_to_check) == 0 ) {
358+ return;
359+ }
360+
361+ // Get the final json to send
362+ var json_link_info = Y.JSON.stringify(links_to_check);
363+ var qs = '';
364+ qs = LP.client.append_qs(qs, 'link_hrefs', json_link_info);
365+
366+ var config = {
367+ on: {
368+ failure: function(id, response, args) {
369+ // If we have firebug installed, log the error.
370+ if( console != undefined ) {
371+ console.log("Link Check Error: " + args + ': '
372+ + response.status + ' - ' +
373+ response.statusText + ' - '
374+ + response.responseXML);
375+ }
376+ },
377+ success: function(id, response) {
378+ var link_info = Y.JSON.parse(response.responseText)
379+ // ATM, we just handle branch links, but in future...
380+ process_invalid_links(Y, link_info, 'branch-short-link',
381+ 'branch_links', "Invalid branch: ");
382+ }
383+ }
384+ }
385+ var uri = '+check-links';
386+ var on = Y.merge(config.on);
387+ var client = this;
388+ var y_config = { method: "POST",
389+ headers: {'Accept': 'application/json'},
390+ on: on,
391+ 'arguments': [client, uri],
392+ data: qs};
393+ Y.io(uri, y_config);
394+ };
395+
396+}, "0.1", {"requires": [
397+ "base", "node", "io", "dom", "json"
398+ ]});
399+
400
401=== modified file 'lib/lp/app/templates/base-layout-macros.pt'
402--- lib/lp/app/templates/base-layout-macros.pt 2010-10-25 13:16:10 +0000
403+++ lib/lp/app/templates/base-layout-macros.pt 2010-10-27 04:31:25 +0000
404@@ -175,6 +175,8 @@
405 <script type="text/javascript"
406 tal:attributes="src string:${lp_js}/app/lp-mochi.js"></script>
407 <script type="text/javascript"
408+ tal:attributes="src string:${lp_js}/app/lp-links.js"></script>
409+ <script type="text/javascript"
410 tal:attributes="src string:${lp_js}/app/dragscroll.js"></script>
411 <script type="text/javascript"
412 tal:attributes="src string:${lp_js}/app/picker.js"></script>
413@@ -304,6 +306,13 @@
414 // anywhere outside of it.
415 Y.on('click', handleClickOnPage, window);
416 });
417+
418+ LPS.use('lp.app.links',
419+ function(Y) {
420+ Y.on('load', function(e) {
421+ Y.lp.app.links.check_valid_lp_links();
422+ }, window);
423+ });
424 </script>
425 </metal:page-javascript>
426
427
428=== modified file 'lib/lp/bugs/windmill/tests/test_bug_commenting.py'
429--- lib/lp/bugs/windmill/tests/test_bug_commenting.py 2010-08-20 20:31:18 +0000
430+++ lib/lp/bugs/windmill/tests/test_bug_commenting.py 2010-10-27 04:31:25 +0000
431@@ -18,7 +18,7 @@
432 WAIT_ELEMENT_COMPLETE = u'30000'
433 WAIT_CHECK_CHANGE = u'1000'
434 ADD_COMMENT_BUTTON = (
435- u'//input[@id="field.actions.save" and @class="button js-action"]')
436+ u'//input[@id="field.actions.save" and contains(@class, "button")]')
437
438
439 class TestBugCommenting(WindmillTestCase):
440
441=== added file 'lib/lp/code/windmill/tests/test_branch_broken_links.py'
442--- lib/lp/code/windmill/tests/test_branch_broken_links.py 1970-01-01 00:00:00 +0000
443+++ lib/lp/code/windmill/tests/test_branch_broken_links.py 2010-10-27 04:31:25 +0000
444@@ -0,0 +1,113 @@
445+# Copyright 2009 Canonical Ltd. This software is licensed under the
446+# GNU Affero General Public License version 3 (see the file LICENSE).
447+
448+"""Test for links between branches and bugs or specs."""
449+
450+__metaclass__ = type
451+__all__ = []
452+
453+import unittest
454+
455+import transaction
456+import windmill
457+from zope.security.proxy import removeSecurityProxy
458+
459+from canonical.launchpad.windmill.testing import lpuser
460+from canonical.launchpad.windmill.testing.constants import SLEEP
461+from lp.code.windmill.testing import CodeWindmillLayer
462+from lp.testing import WindmillTestCase
463+
464+
465+ADD_COMMENT_BUTTON = (
466+ u'//input[@id="field.actions.save" and contains(@class, "button")]')
467+
468+
469+class TestBranchLinks(WindmillTestCase):
470+ """Test the rendering of broken branch links."""
471+
472+ layer = CodeWindmillLayer
473+ suite_name = "Broken branch links"
474+
475+ BUG_TEXT_TEMPLATE = u"""
476+ Here is the bug. Which branches are valid?
477+ Valid: %s
478+ Invalid %s
479+ """
480+
481+ BRANCH_URL_TEMPLATE = "lp:%s"
482+
483+ def make_product_and_valid_links(self):
484+ branch = self.factory.makeProductBranch()
485+ valid_branch_url = self.BRANCH_URL_TEMPLATE % branch.unique_name
486+ product = self.factory.makeProduct()
487+ product_branch = self.factory.makeProductBranch(product=product)
488+ naked_product = removeSecurityProxy(product)
489+ naked_product.development_focus.branch = product_branch
490+ valid_product_url = self.BRANCH_URL_TEMPLATE % product.name
491+
492+ return (naked_product, [
493+ valid_branch_url,
494+ valid_product_url,
495+ ])
496+
497+ def make_invalid_links(self):
498+ return [
499+ self.BRANCH_URL_TEMPLATE % 'foo',
500+ self.BRANCH_URL_TEMPLATE % 'bar',
501+ ]
502+
503+ def test_invalid_url_rendering(self):
504+ """Link a bug from the branch page."""
505+ client = self.client
506+
507+ lpuser.FOO_BAR.ensure_login(client)
508+
509+ naked_product, valid_links = self.make_product_and_valid_links()
510+ invalid_links = self.make_invalid_links()
511+ bug_description = self.BUG_TEXT_TEMPLATE % (
512+ ', '.join(valid_links), ', '.join(invalid_links))
513+ bug = self.factory.makeBug(product=naked_product,
514+ title="The meaning of life is broken",
515+ description=bug_description)
516+ transaction.commit()
517+
518+ bug_url = (
519+ windmill.settings['TEST_URL'] + '%s/+bug/%s'
520+ % (naked_product.name, bug.id))
521+ client.open(url=bug_url)
522+ client.waits.forElement(xpath=ADD_COMMENT_BUTTON)
523+
524+ # Let the Ajax call run
525+ client.waits.sleep(milliseconds=SLEEP)
526+
527+ code = """
528+ var good_a = windmill.testWin().document.getElementsByClassName(
529+ 'branch-short-link', 'a');
530+ var good_links = [];
531+ for( i=0; i<good_a.length; i++ ) {
532+ good_links.push(good_a[i].innerHTML);
533+ }
534+
535+ var bad_a = windmill.testWin().document.getElementsByClassName(
536+ 'invalid-link', 'a');
537+ var bad_links = [];
538+ for( i=0; i<bad_a.length; i++ ) {
539+ bad_links.push(bad_a[i].innerHTML);
540+ }
541+
542+
543+ var result = {};
544+ result.good = good_links;
545+ result.bad = bad_links;
546+ result
547+ """
548+ raw_result = self.client.commands.execJS(js=code)
549+ result = raw_result['result']
550+ result_valid_links = result['good']
551+ result_invalid_links = result['bad']
552+ self.assertEqual(set(invalid_links), set(result_invalid_links))
553+ self.assertEqual(set(valid_links), set(result_valid_links))
554+
555+
556+def test_suite():
557+ return unittest.TestLoader().loadTestsFromName(__name__)
558
559=== modified file 'lib/lp/code/windmill/tests/test_branchmergeproposal_review.py'
560--- lib/lp/code/windmill/tests/test_branchmergeproposal_review.py 2010-08-20 20:31:18 +0000
561+++ lib/lp/code/windmill/tests/test_branchmergeproposal_review.py 2010-10-27 04:31:25 +0000
562@@ -27,7 +27,7 @@
563 WAIT_ELEMENT_COMPLETE = u'30000'
564 WAIT_CHECK_CHANGE = u'1000'
565 ADD_COMMENT_BUTTON = (
566- u'//input[@id="field.actions.add" and @class="button js-action"]')
567+ u'//input[@id="field.actions.add" and contains(@class, "button")]')
568
569
570 class TestRequestReview(WindmillTestCase):