Merge lp:~cjwatson/launchpad/github-link into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18130
Proposed branch: lp:~cjwatson/launchpad/github-link
Merge into: lp:launchpad
Diff against target: 1401 lines (+876/-220)
15 files modified
lib/lp/bugs/configure.zcml (+12/-1)
lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt (+1/-2)
lib/lp/bugs/doc/externalbugtracker-bugzilla.txt (+1/-1)
lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt (+2/-4)
lib/lp/bugs/doc/externalbugtracker-trac.txt (+1/-2)
lib/lp/bugs/doc/externalbugtracker.txt (+6/-5)
lib/lp/bugs/externalbugtracker/__init__.py (+33/-9)
lib/lp/bugs/externalbugtracker/base.py (+3/-2)
lib/lp/bugs/externalbugtracker/github.py (+264/-0)
lib/lp/bugs/externalbugtracker/tests/test_github.py (+375/-0)
lib/lp/bugs/interfaces/bugtracker.py (+8/-1)
lib/lp/bugs/model/bugtracker.py (+6/-1)
lib/lp/bugs/model/bugwatch.py (+15/-1)
lib/lp/bugs/stories/bugtracker/xx-bugtracker.txt (+1/-0)
lib/lp/bugs/tests/test_bugwatch.py (+148/-191)
To merge this branch: bzr merge lp:~cjwatson/launchpad/github-link
Reviewer Review Type Date Requested Status
Thomi Richards (community) Approve
Launchpad code reviewers Pending
Review via email: mp+299074@code.launchpad.net

Commit message

Add basic GitHub bug linking.

Description of the change

Add basic GitHub bug linking.

Each repository with linked issues needs its own BugTracker row, as GitHub namespaces issue numbers per repository rather than globally. However, our rate limit for GitHub API requests is global, and GitHub's documentation indicates that they may take punitive measures against clients that don't do a good job of backing off when they exceed their rate limit. This necessitates a GitHubRateLimit utility to keep track of how much of our limit we have left.

Our model of remote bug state is very simple for now. We map "open" to NEW, "closed" to FIXRELEASED, and ignore importance altogether. However, we do at least record the remote bug's labels even though we don't currently map them to anything.

On deployment, we'll want to get sysadmins to create a bot account for us on GitHub and generate an OAuth token. While unauthenticated access will minimally work, GitHub sets a much lower rate limit for that than for authenticated access.

To post a comment you must log in.
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

LGTM - This is another branch where we're inheriting test cases, which makes me sad...

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml 2016-02-04 12:45:38 +0000
+++ lib/lp/bugs/configure.zcml 2016-07-13 10:12:00 +0000
@@ -1,4 +1,4 @@
1<!-- Copyright 2009-2012 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2016 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -518,6 +518,17 @@
518 interface="lp.bugs.interfaces.bugtracker.IRemoteBug"/>518 interface="lp.bugs.interfaces.bugtracker.IRemoteBug"/>
519 </class>519 </class>
520520
521 <!-- GitHubRateLimit -->
522
523 <class
524 class="lp.bugs.externalbugtracker.github.GitHubRateLimit">
525 <allow
526 interface="lp.bugs.externalbugtracker.github.IGitHubRateLimit"/>
527 </class>
528 <utility
529 factory="lp.bugs.externalbugtracker.github.GitHubRateLimit"
530 provides="lp.bugs.externalbugtracker.github.IGitHubRateLimit"/>
531
521 <!-- IBugBranch -->532 <!-- IBugBranch -->
522533
523 <class534 <class
524535
=== modified file 'lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt'
--- lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt 2012-12-26 01:32:19 +0000
+++ lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt 2016-07-13 10:12:00 +0000
@@ -7,8 +7,7 @@
7For testing purposes, a custom XML-RPC transport can be passed to it,7For testing purposes, a custom XML-RPC transport can be passed to it,
8so that we can avoid network traffic in tests.8so that we can avoid network traffic in tests.
99
10 >>> from lp.bugs.externalbugtracker import (10 >>> from lp.bugs.externalbugtracker.bugzilla import BugzillaLPPlugin
11 ... BugzillaLPPlugin)
12 >>> from lp.bugs.tests.externalbugtracker import (11 >>> from lp.bugs.tests.externalbugtracker import (
13 ... TestBugzillaXMLRPCTransport)12 ... TestBugzillaXMLRPCTransport)
14 >>> test_transport = TestBugzillaXMLRPCTransport('http://example.com/')13 >>> test_transport = TestBugzillaXMLRPCTransport('http://example.com/')
1514
=== modified file 'lib/lp/bugs/doc/externalbugtracker-bugzilla.txt'
--- lib/lp/bugs/doc/externalbugtracker-bugzilla.txt 2012-12-26 01:32:19 +0000
+++ lib/lp/bugs/doc/externalbugtracker-bugzilla.txt 2016-07-13 10:12:00 +0000
@@ -113,7 +113,7 @@
113113
114 >>> transaction.commit()114 >>> transaction.commit()
115115
116 >>> from lp.bugs.externalbugtracker import (116 >>> from lp.bugs.externalbugtracker.bugzilla import (
117 ... BugzillaAPI, BugzillaLPPlugin)117 ... BugzillaAPI, BugzillaLPPlugin)
118 >>> bugzilla_to_use = bugzilla.getExternalBugTrackerToUse()118 >>> bugzilla_to_use = bugzilla.getExternalBugTrackerToUse()
119119
120120
=== modified file 'lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt'
--- lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2012-11-02 03:23:34 +0000
+++ lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2016-07-13 10:12:00 +0000
@@ -7,10 +7,8 @@
7For testing purposes, a custom XML-RPC transport can be passed to it,7For testing purposes, a custom XML-RPC transport can be passed to it,
8so that we can avoid network traffic in tests.8so that we can avoid network traffic in tests.
99
10 >>> from lp.bugs.externalbugtracker import (10 >>> from lp.bugs.externalbugtracker.trac import TracLPPlugin
11 ... TracLPPlugin)11 >>> from lp.bugs.tests.externalbugtracker import TestTracXMLRPCTransport
12 >>> from lp.bugs.tests.externalbugtracker import (
13 ... TestTracXMLRPCTransport)
14 >>> test_transport = TestTracXMLRPCTransport('http://example.com/')12 >>> test_transport = TestTracXMLRPCTransport('http://example.com/')
15 >>> trac = TracLPPlugin(13 >>> trac = TracLPPlugin(
16 ... 'http://example.com/', xmlrpc_transport=test_transport)14 ... 'http://example.com/', xmlrpc_transport=test_transport)
1715
=== modified file 'lib/lp/bugs/doc/externalbugtracker-trac.txt'
--- lib/lp/bugs/doc/externalbugtracker-trac.txt 2012-12-26 01:32:19 +0000
+++ lib/lp/bugs/doc/externalbugtracker-trac.txt 2016-07-13 10:12:00 +0000
@@ -41,8 +41,7 @@
41 >>> chosen_trac = trac.getExternalBugTrackerToUse()41 >>> chosen_trac = trac.getExternalBugTrackerToUse()
42 http://example.com/launchpad-auth/check42 http://example.com/launchpad-auth/check
4343
44 >>> from lp.bugs.externalbugtracker import (44 >>> from lp.bugs.externalbugtracker.trac import TracLPPlugin
45 ... TracLPPlugin)
46 >>> isinstance(chosen_trac, TracLPPlugin)45 >>> isinstance(chosen_trac, TracLPPlugin)
47 True46 True
48 >>> chosen_trac.baseurl47 >>> chosen_trac.baseurl
4948
=== modified file 'lib/lp/bugs/doc/externalbugtracker.txt'
--- lib/lp/bugs/doc/externalbugtracker.txt 2016-02-05 16:51:12 +0000
+++ lib/lp/bugs/doc/externalbugtracker.txt 2016-07-13 10:12:00 +0000
@@ -49,8 +49,7 @@
49instance. Usually there is only one version, so the default for the49instance. Usually there is only one version, so the default for the
50original instance is to return itself.50original instance is to return itself.
5151
52 >>> from lp.bugs.externalbugtracker import (52 >>> from lp.bugs.externalbugtracker import ExternalBugTracker
53 ... ExternalBugTracker)
54 >>> external_bugtracker = ExternalBugTracker('http://example.com/')53 >>> external_bugtracker = ExternalBugTracker('http://example.com/')
55 >>> chosen_bugtracker = external_bugtracker.getExternalBugTrackerToUse()54 >>> chosen_bugtracker = external_bugtracker.getExternalBugTrackerToUse()
56 >>> chosen_bugtracker is external_bugtracker55 >>> chosen_bugtracker is external_bugtracker
@@ -63,9 +62,11 @@
63(ExternalBugTracker, bug_watches) tuples.62(ExternalBugTracker, bug_watches) tuples.
6463
65 >>> from lp.bugs.externalbugtracker import (64 >>> from lp.bugs.externalbugtracker import (
66 ... Bugzilla, BugzillaAPI, BUG_TRACKER_CLASSES)65 ... Bugzilla,
67 >>> from lp.bugs.interfaces.bugtracker import (66 ... BUG_TRACKER_CLASSES,
68 ... BugTrackerType)67 ... )
68 >>> from lp.bugs.externalbugtracker.bugzilla import BugzillaAPI
69 >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
69 >>> from lp.testing.factory import LaunchpadObjectFactory70 >>> from lp.testing.factory import LaunchpadObjectFactory
7071
71 >>> factory = LaunchpadObjectFactory()72 >>> factory = LaunchpadObjectFactory()
7273
=== modified file 'lib/lp/bugs/externalbugtracker/__init__.py'
--- lib/lp/bugs/externalbugtracker/__init__.py 2013-01-07 02:40:55 +0000
+++ lib/lp/bugs/externalbugtracker/__init__.py 2016-07-13 10:12:00 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2011 Canonical Ltd. This software is licensed under the1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""__init__ module for the externalbugtracker package."""4"""__init__ module for the externalbugtracker package."""
@@ -14,6 +14,7 @@
14 'DebBugs',14 'DebBugs',
15 'DebBugsDatabaseNotFound',15 'DebBugsDatabaseNotFound',
16 'ExternalBugTracker',16 'ExternalBugTracker',
17 'GitHub',
17 'InvalidBugId',18 'InvalidBugId',
18 'LookupTree',19 'LookupTree',
19 'Mantis',20 'Mantis',
@@ -31,20 +32,43 @@
31 'get_external_bugtracker',32 'get_external_bugtracker',
32 ]33 ]
3334
34from lp.bugs.externalbugtracker.base import *35from lp.bugs.externalbugtracker.base import (
35from lp.bugs.externalbugtracker.bugzilla import *36 BATCH_SIZE_UNLIMITED,
36from lp.bugs.externalbugtracker.debbugs import *37 BugNotFound,
37from lp.bugs.externalbugtracker.mantis import *38 BugTrackerConnectError,
38from lp.bugs.externalbugtracker.roundup import *39 BugWatchUpdateError,
39from lp.bugs.externalbugtracker.rt import *40 BugWatchUpdateWarning,
40from lp.bugs.externalbugtracker.sourceforge import *41 ExternalBugTracker,
41from lp.bugs.externalbugtracker.trac import *42 InvalidBugId,
43 LookupTree,
44 PrivateRemoteBug,
45 UnknownBugTrackerTypeError,
46 UnknownRemoteStatusError,
47 UnparsableBugData,
48 UnparsableBugTrackerVersion,
49 UnsupportedBugTrackerVersion,
50 )
51from lp.bugs.externalbugtracker.bugzilla import Bugzilla
52from lp.bugs.externalbugtracker.debbugs import (
53 DebBugs,
54 DebBugsDatabaseNotFound,
55 )
56from lp.bugs.externalbugtracker.github import GitHub
57from lp.bugs.externalbugtracker.mantis import (
58 Mantis,
59 MantisLoginHandler,
60 )
61from lp.bugs.externalbugtracker.roundup import Roundup
62from lp.bugs.externalbugtracker.rt import RequestTracker
63from lp.bugs.externalbugtracker.sourceforge import SourceForge
64from lp.bugs.externalbugtracker.trac import Trac
42from lp.bugs.interfaces.bugtracker import BugTrackerType65from lp.bugs.interfaces.bugtracker import BugTrackerType
4366
4467
45BUG_TRACKER_CLASSES = {68BUG_TRACKER_CLASSES = {
46 BugTrackerType.BUGZILLA: Bugzilla,69 BugTrackerType.BUGZILLA: Bugzilla,
47 BugTrackerType.DEBBUGS: DebBugs,70 BugTrackerType.DEBBUGS: DebBugs,
71 BugTrackerType.GITHUB: GitHub,
48 BugTrackerType.MANTIS: Mantis,72 BugTrackerType.MANTIS: Mantis,
49 BugTrackerType.TRAC: Trac,73 BugTrackerType.TRAC: Trac,
50 BugTrackerType.ROUNDUP: Roundup,74 BugTrackerType.ROUNDUP: Roundup,
5175
=== modified file 'lib/lp/bugs/externalbugtracker/base.py'
--- lib/lp/bugs/externalbugtracker/base.py 2015-07-08 16:05:11 +0000
+++ lib/lp/bugs/externalbugtracker/base.py 2016-07-13 10:12:00 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2013 Canonical Ltd. This software is licensed under the1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""External bugtrackers."""4"""External bugtrackers."""
@@ -14,6 +14,7 @@
14 'ExternalBugTracker',14 'ExternalBugTracker',
15 'InvalidBugId',15 'InvalidBugId',
16 'LookupTree',16 'LookupTree',
17 'LP_USER_AGENT',
17 'PrivateRemoteBug',18 'PrivateRemoteBug',
18 'UnknownBugTrackerTypeError',19 'UnknownBugTrackerTypeError',
19 'UnknownRemoteImportanceError',20 'UnknownRemoteImportanceError',
@@ -236,7 +237,7 @@
236 def _getHeaders(self):237 def _getHeaders(self):
237 # For some reason, bugs.kde.org doesn't allow the regular urllib238 # For some reason, bugs.kde.org doesn't allow the regular urllib
238 # user-agent string (Python-urllib/2.x) to access their bugzilla.239 # user-agent string (Python-urllib/2.x) to access their bugzilla.
239 return {'User-agent': LP_USER_AGENT, 'Host': self.basehost}240 return {'User-Agent': LP_USER_AGENT, 'Host': self.basehost}
240241
241 def _fetchPage(self, page, data=None):242 def _fetchPage(self, page, data=None):
242 """Fetch a page from the remote server.243 """Fetch a page from the remote server.
243244
=== added file 'lib/lp/bugs/externalbugtracker/github.py'
--- lib/lp/bugs/externalbugtracker/github.py 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/externalbugtracker/github.py 2016-07-13 10:12:00 +0000
@@ -0,0 +1,264 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""GitHub ExternalBugTracker utility."""
5
6__metaclass__ = type
7__all__ = [
8 'BadGitHubURL',
9 'GitHub',
10 'GitHubRateLimit',
11 'IGitHubRateLimit',
12 ]
13
14import httplib
15import time
16from urllib import urlencode
17from urlparse import (
18 urljoin,
19 urlunsplit,
20 )
21
22import pytz
23import requests
24from zope.component import getUtility
25from zope.interface import Interface
26
27from lp.bugs.externalbugtracker import (
28 BugTrackerConnectError,
29 BugWatchUpdateError,
30 ExternalBugTracker,
31 UnknownRemoteStatusError,
32 UnparsableBugTrackerVersion,
33 )
34from lp.bugs.externalbugtracker.base import LP_USER_AGENT
35from lp.bugs.interfaces.bugtask import (
36 BugTaskImportance,
37 BugTaskStatus,
38 )
39from lp.bugs.interfaces.externalbugtracker import UNKNOWN_REMOTE_IMPORTANCE
40from lp.services.config import config
41from lp.services.database.isolation import ensure_no_transaction
42from lp.services.webapp.url import urlsplit
43
44
45class GitHubExceededRateLimit(BugWatchUpdateError):
46
47 def __init__(self, host, reset):
48 self.host = host
49 self.reset = reset
50
51 def __str__(self):
52 return "Rate limit for %s exceeded (resets at %s)" % (
53 self.host, time.ctime(self.reset))
54
55
56class IGitHubRateLimit(Interface):
57 """Interface for rate-limit tracking for the GitHub Issues API."""
58
59 def makeRequest(method, url, token=None, **kwargs):
60 """Make a request, but only if the remote host's rate limit permits it.
61
62 :param method: The HTTP request method.
63 :param url: The URL to request.
64 :param token: If not None, an OAuth token to use as authentication
65 to the remote host when asking it for the current rate limit.
66 :return: A `requests.Response` object.
67 :raises GitHubExceededRateLimit: if the rate limit was exceeded.
68 """
69
70 def clearCache():
71 """Forget any cached rate limits."""
72
73
74class GitHubRateLimit:
75 """Rate-limit tracking for the GitHub Issues API."""
76
77 def __init__(self):
78 self.clearCache()
79
80 def _update(self, host, token=None):
81 headers = {
82 "User-Agent": LP_USER_AGENT,
83 "Host": host,
84 "Accept": "application/vnd.github.v3+json",
85 }
86 if token is not None:
87 headers["Authorization"] = "token %s" % token
88 url = "https://%s/rate_limit" % host
89 try:
90 response = requests.get(url, headers=headers)
91 response.raise_for_status()
92 self._limits[(host, token)] = response.json()["resources"]["core"]
93 except requests.RequestException as e:
94 raise BugTrackerConnectError(url, e)
95
96 @ensure_no_transaction
97 def makeRequest(self, method, url, token=None, **kwargs):
98 """See `IGitHubRateLimit`."""
99 host = urlsplit(url).netloc
100 if (host, token) not in self._limits:
101 self._update(host, token=token)
102 limit = self._limits[(host, token)]
103 if not limit["remaining"]:
104 raise GitHubExceededRateLimit(host, limit["reset"])
105 response = requests.request(method, url, **kwargs)
106 limit["remaining"] -= 1
107 return response
108
109 def clearCache(self):
110 """See `IGitHubRateLimit`."""
111 self._limits = {}
112
113
114class BadGitHubURL(UnparsableBugTrackerVersion):
115 """The GitHub Issues URL is malformed."""
116
117
118class GitHub(ExternalBugTracker):
119 """An `ExternalBugTracker` for dealing with GitHub issues."""
120
121 # Avoid eating through our rate limit unnecessarily.
122 batch_query_threshold = 1
123
124 def __init__(self, baseurl):
125 _, host, path, query, fragment = urlsplit(baseurl)
126 host = "api." + host
127 path = path.rstrip("/")
128 if not path.endswith("/issues"):
129 raise BadGitHubURL(baseurl)
130 path = "/repos" + path[:-len("/issues")]
131 baseurl = urlunsplit(("https", host, path, query, fragment))
132 super(GitHub, self).__init__(baseurl)
133 self.cached_bugs = {}
134
135 @property
136 def credentials(self):
137 credentials_config = config["checkwatches.credentials"]
138 # lazr.config.Section doesn't support get().
139 try:
140 token = credentials_config["%s.token" % self.basehost]
141 except KeyError:
142 token = None
143 return {"token": token}
144
145 def getModifiedRemoteBugs(self, bug_ids, last_accessed):
146 """See `IExternalBugTracker`."""
147 modified_bugs = self.getRemoteBugBatch(
148 bug_ids, last_accessed=last_accessed)
149 self.cached_bugs.update(modified_bugs)
150 return list(modified_bugs)
151
152 def getRemoteBug(self, bug_id):
153 """See `ExternalBugTracker`."""
154 bug_id = int(bug_id)
155 if bug_id not in self.cached_bugs:
156 self.cached_bugs[bug_id] = (
157 self._getPage("issues/%s" % bug_id).json())
158 return bug_id, self.cached_bugs[bug_id]
159
160 def getRemoteBugBatch(self, bug_ids, last_accessed=None):
161 """See `ExternalBugTracker`."""
162 # The GitHub API does not support exporting only a subset of bug IDs
163 # as a batch. As a result, our caching is only effective if we have
164 # cached *all* the requested bug IDs; this is the case when we're
165 # being called on the result of getModifiedRemoteBugs, so it's still
166 # a useful optimisation.
167 bug_ids = [int(bug_id) for bug_id in bug_ids]
168 bugs = {
169 bug_id: self.cached_bugs[bug_id]
170 for bug_id in bug_ids if bug_id in self.cached_bugs}
171 if set(bugs) == set(bug_ids):
172 return bugs
173 params = [("state", "all")]
174 if last_accessed is not None:
175 since = last_accessed.astimezone(pytz.UTC).strftime(
176 "%Y-%m-%dT%H:%M:%SZ")
177 params.append(("since", since))
178 page = "issues?%s" % urlencode(params)
179 for remote_bug in self._getCollection(page):
180 # We're only interested in the bug if it's one of the ones in
181 # bug_ids.
182 if remote_bug["id"] not in bug_ids:
183 continue
184 bugs[remote_bug["id"]] = remote_bug
185 self.cached_bugs[remote_bug["id"]] = remote_bug
186 return bugs
187
188 def getRemoteImportance(self, bug_id):
189 """See `ExternalBugTracker`."""
190 return UNKNOWN_REMOTE_IMPORTANCE
191
192 def getRemoteStatus(self, bug_id):
193 """See `ExternalBugTracker`."""
194 remote_bug = self.bugs[int(bug_id)]
195 state = remote_bug["state"]
196 labels = [label["name"] for label in remote_bug["labels"]]
197 return " ".join([state] + labels)
198
199 def convertRemoteImportance(self, remote_importance):
200 """See `IExternalBugTracker`."""
201 return BugTaskImportance.UNKNOWN
202
203 def convertRemoteStatus(self, remote_status):
204 """See `IExternalBugTracker`.
205
206 A GitHub status consists of the state followed by optional labels.
207 """
208 state = remote_status.split(" ", 1)[0]
209 if state == "open":
210 return BugTaskStatus.NEW
211 elif state == "closed":
212 return BugTaskStatus.FIXRELEASED
213 else:
214 raise UnknownRemoteStatusError(remote_status)
215
216 def _getHeaders(self, last_accessed=None):
217 """See `ExternalBugTracker`."""
218 headers = super(GitHub, self)._getHeaders()
219 token = self.credentials["token"]
220 if token is not None:
221 headers["Authorization"] = "token %s" % token
222 headers["Accept"] = "application/vnd.github.v3+json"
223 if last_accessed is not None:
224 headers["If-Modified-Since"] = (
225 last_accessed.astimezone(pytz.UTC).strftime(
226 "%a, %d %b %Y %H:%M:%S GMT"))
227 return headers
228
229 def _getPage(self, page, last_accessed=None):
230 """See `ExternalBugTracker`."""
231 # We prefer to use requests here because it knows how to parse Link
232 # headers. Note that this returns a `requests.Response`, not the
233 # page data.
234 try:
235 response = getUtility(IGitHubRateLimit).makeRequest(
236 "GET", urljoin(self.baseurl + "/", page),
237 headers=self._getHeaders(last_accessed=last_accessed))
238 response.raise_for_status()
239 return response
240 except requests.RequestException as e:
241 raise BugTrackerConnectError(self.baseurl, e)
242
243 def _getCollection(self, base_page, last_accessed=None):
244 """Yield each item from a batched remote collection.
245
246 If the collection has not been modified since `last_accessed`, yield
247 no items.
248 """
249 page = base_page
250 while page is not None:
251 try:
252 response = self._getPage(page, last_accessed=last_accessed)
253 except BugTrackerConnectError as e:
254 if (e.response is not None and
255 e.response.status_code == httplib.NOT_MODIFIED):
256 return
257 else:
258 raise
259 for item in response.json():
260 yield item
261 if "next" in response.links:
262 page = response.links["next"]["url"]
263 else:
264 page = None
0265
=== added file 'lib/lp/bugs/externalbugtracker/tests/test_github.py'
--- lib/lp/bugs/externalbugtracker/tests/test_github.py 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/externalbugtracker/tests/test_github.py 2016-07-13 10:12:00 +0000
@@ -0,0 +1,375 @@
1# Copyright 2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for the GitHub Issues BugTracker."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10from datetime import datetime
11import json
12from urlparse import (
13 parse_qs,
14 urlunsplit,
15 )
16
17from httmock import (
18 HTTMock,
19 urlmatch,
20 )
21import pytz
22import transaction
23from zope.component import getUtility
24
25from lp.app.interfaces.launchpad import ILaunchpadCelebrities
26from lp.bugs.externalbugtracker import (
27 BugTrackerConnectError,
28 get_external_bugtracker,
29 )
30from lp.bugs.externalbugtracker.github import (
31 BadGitHubURL,
32 GitHub,
33 GitHubExceededRateLimit,
34 IGitHubRateLimit,
35 )
36from lp.bugs.interfaces.bugtask import BugTaskStatus
37from lp.bugs.interfaces.bugtracker import BugTrackerType
38from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
39from lp.bugs.scripts.checkwatches import CheckwatchesMaster
40from lp.services.log.logger import BufferLogger
41from lp.testing import (
42 TestCase,
43 TestCaseWithFactory,
44 verifyObject,
45 )
46from lp.testing.layers import (
47 ZopelessDatabaseLayer,
48 ZopelessLayer,
49 )
50
51
52class TestGitHubRateLimit(TestCase):
53
54 layer = ZopelessLayer
55
56 def setUp(self):
57 super(TestGitHubRateLimit, self).setUp()
58 self.rate_limit = getUtility(IGitHubRateLimit)
59 self.addCleanup(self.rate_limit.clearCache)
60
61 @urlmatch(path=r"^/rate_limit$")
62 def _rate_limit_handler(self, url, request):
63 self.rate_limit_request = request
64 self.rate_limit_headers = request.headers
65 return {
66 "status_code": 200,
67 "content": {"resources": {"core": self.initial_rate_limit}},
68 }
69
70 @urlmatch(path=r"^/$")
71 def _target_handler(self, url, request):
72 self.target_request = request
73 return {"status_code": 200, "content": b"test"}
74
75 def test_makeRequest_no_token(self):
76 self.initial_rate_limit = {
77 "limit": 60, "remaining": 50, "reset": 1000000000}
78 with HTTMock(self._rate_limit_handler, self._target_handler):
79 response = self.rate_limit.makeRequest(
80 "GET", "http://example.org/")
81 self.assertNotIn("Authorization", self.rate_limit_headers)
82 self.assertEqual(b"test", response.content)
83 limit = self.rate_limit._limits[("example.org", None)]
84 self.assertEqual(49, limit["remaining"])
85 self.assertEqual(1000000000, limit["reset"])
86
87 limit["remaining"] = 0
88 self.rate_limit_request = None
89 with HTTMock(self._rate_limit_handler, self._target_handler):
90 self.assertRaisesWithContent(
91 GitHubExceededRateLimit,
92 "Rate limit for example.org exceeded "
93 "(resets at Sun Sep 9 07:16:40 2001)",
94 self.rate_limit.makeRequest,
95 "GET", "http://example.org/")
96 self.assertIsNone(self.rate_limit_request)
97 self.assertEqual(0, limit["remaining"])
98
99 def test_makeRequest_check_token(self):
100 self.initial_rate_limit = {
101 "limit": 5000, "remaining": 4000, "reset": 1000000000}
102 with HTTMock(self._rate_limit_handler, self._target_handler):
103 response = self.rate_limit.makeRequest(
104 "GET", "http://example.org/", token="abc")
105 self.assertEqual("token abc", self.rate_limit_headers["Authorization"])
106 self.assertEqual(b"test", response.content)
107 limit = self.rate_limit._limits[("example.org", "abc")]
108 self.assertEqual(3999, limit["remaining"])
109 self.assertEqual(1000000000, limit["reset"])
110
111 limit["remaining"] = 0
112 self.rate_limit_request = None
113 with HTTMock(self._rate_limit_handler, self._target_handler):
114 self.assertRaisesWithContent(
115 GitHubExceededRateLimit,
116 "Rate limit for example.org exceeded "
117 "(resets at Sun Sep 9 07:16:40 2001)",
118 self.rate_limit.makeRequest,
119 "GET", "http://example.org/", token="abc")
120 self.assertIsNone(self.rate_limit_request)
121 self.assertEqual(0, limit["remaining"])
122
123 def test_makeRequest_check_503(self):
124 @urlmatch(path=r"^/rate_limit$")
125 def rate_limit_handler(url, request):
126 return {"status_code": 503}
127
128 with HTTMock(rate_limit_handler):
129 self.assertRaises(
130 BugTrackerConnectError, self.rate_limit.makeRequest,
131 "GET", "http://example.org/")
132
133
134class TestGitHub(TestCase):
135
136 layer = ZopelessLayer
137
138 def setUp(self):
139 super(TestGitHub, self).setUp()
140 self.addCleanup(getUtility(IGitHubRateLimit).clearCache)
141 self.sample_bugs = [
142 {"id": 1, "state": "open", "labels": []},
143 {"id": 2, "state": "open", "labels": [{"name": "feature"}]},
144 {"id": 3, "state": "open",
145 "labels": [{"name": "feature"}, {"name": "ui"}]},
146 {"id": 4, "state": "closed", "labels": []},
147 {"id": 5, "state": "closed", "labels": [{"name": "feature"}]},
148 ]
149
150 def test_implements_interface(self):
151 self.assertTrue(verifyObject(
152 IExternalBugTracker,
153 GitHub("https://github.com/user/repository/issues")))
154
155 def test_requires_issues_url(self):
156 self.assertRaises(
157 BadGitHubURL, GitHub, "https://github.com/user/repository")
158
159 @urlmatch(path=r"^/rate_limit$")
160 def _rate_limit_handler(self, url, request):
161 self.rate_limit_request = request
162 rate_limit = {"limit": 5000, "remaining": 4000, "reset": 1000000000}
163 return {
164 "status_code": 200,
165 "content": {"resources": {"core": rate_limit}},
166 }
167
168 def test_getRemoteBug(self):
169 @urlmatch(path=r".*/issues/1$")
170 def handler(url, request):
171 self.request = request
172 return {"status_code": 200, "content": self.sample_bugs[0]}
173
174 tracker = GitHub("https://github.com/user/repository/issues")
175 with HTTMock(self._rate_limit_handler, handler):
176 self.assertEqual(
177 (1, self.sample_bugs[0]), tracker.getRemoteBug("1"))
178 self.assertEqual(
179 "https://api.github.com/repos/user/repository/issues/1",
180 self.request.url)
181
182 @urlmatch(path=r".*/issues$")
183 def _issues_handler(self, url, request):
184 self.issues_request = request
185 return {"status_code": 200, "content": json.dumps(self.sample_bugs)}
186
187 def test_getRemoteBugBatch(self):
188 tracker = GitHub("https://github.com/user/repository/issues")
189 with HTTMock(self._rate_limit_handler, self._issues_handler):
190 self.assertEqual(
191 {bug["id"]: bug for bug in self.sample_bugs[:2]},
192 tracker.getRemoteBugBatch(["1", "2"]))
193 self.assertEqual(
194 "https://api.github.com/repos/user/repository/issues?state=all",
195 self.issues_request.url)
196
197 def test_getRemoteBugBatch_last_accessed(self):
198 tracker = GitHub("https://github.com/user/repository/issues")
199 since = datetime(2015, 1, 1, 12, 0, 0, tzinfo=pytz.UTC)
200 with HTTMock(self._rate_limit_handler, self._issues_handler):
201 self.assertEqual(
202 {bug["id"]: bug for bug in self.sample_bugs[:2]},
203 tracker.getRemoteBugBatch(["1", "2"], last_accessed=since))
204 self.assertEqual(
205 "https://api.github.com/repos/user/repository/issues?"
206 "state=all&since=2015-01-01T12%3A00%3A00Z",
207 self.issues_request.url)
208
209 def test_getRemoteBugBatch_caching(self):
210 tracker = GitHub("https://github.com/user/repository/issues")
211 with HTTMock(self._rate_limit_handler, self._issues_handler):
212 tracker.initializeRemoteBugDB(
213 [str(bug["id"]) for bug in self.sample_bugs])
214 self.issues_request = None
215 self.assertEqual(
216 {bug["id"]: bug for bug in self.sample_bugs[:2]},
217 tracker.getRemoteBugBatch(["1", "2"]))
218 self.assertIsNone(self.issues_request)
219
220 def test_getRemoteBugBatch_pagination(self):
221 @urlmatch(path=r".*/issues")
222 def handler(url, request):
223 self.issues_requests.append(request)
224 base_url = urlunsplit(list(url[:3]) + ["", ""])
225 page = int(parse_qs(url.query).get("page", ["1"])[0])
226 links = []
227 if page != 3:
228 links.append('<%s?page=%d>; rel="next"' % (base_url, page + 1))
229 links.append('<%s?page=3>; rel="last"' % base_url)
230 if page != 1:
231 links.append('<%s?page=1>; rel="first"' % base_url)
232 links.append('<%s?page=%d>; rel="prev"' % (base_url, page - 1))
233 start = (page - 1) * 2
234 end = page * 2
235 return {
236 "status_code": 200,
237 "headers": {"Link": ", ".join(links)},
238 "content": json.dumps(self.sample_bugs[start:end]),
239 }
240
241 self.issues_requests = []
242 tracker = GitHub("https://github.com/user/repository/issues")
243 with HTTMock(self._rate_limit_handler, handler):
244 self.assertEqual(
245 {bug["id"]: bug for bug in self.sample_bugs},
246 tracker.getRemoteBugBatch(
247 [str(bug["id"]) for bug in self.sample_bugs]))
248 expected_urls = [
249 "https://api.github.com/repos/user/repository/issues?state=all",
250 "https://api.github.com/repos/user/repository/issues?page=2",
251 "https://api.github.com/repos/user/repository/issues?page=3",
252 ]
253 self.assertEqual(
254 expected_urls, [request.url for request in self.issues_requests])
255
256 def test_status_open(self):
257 self.sample_bugs = [
258 {"id": 1, "state": "open", "labels": []},
259 # Labels do not affect status, even if names collide.
260 {"id": 2, "state": "open",
261 "labels": [{"name": "feature"}, {"name": "closed"}]},
262 ]
263 tracker = GitHub("https://github.com/user/repository/issues")
264 with HTTMock(self._rate_limit_handler, self._issues_handler):
265 tracker.initializeRemoteBugDB(["1", "2"])
266 remote_status = tracker.getRemoteStatus("1")
267 self.assertEqual("open", remote_status)
268 lp_status = tracker.convertRemoteStatus(remote_status)
269 self.assertEqual(BugTaskStatus.NEW, lp_status)
270 remote_status = tracker.getRemoteStatus("2")
271 self.assertEqual("open feature closed", remote_status)
272 lp_status = tracker.convertRemoteStatus(remote_status)
273 self.assertEqual(BugTaskStatus.NEW, lp_status)
274
275 def test_status_closed(self):
276 self.sample_bugs = [
277 {"id": 1, "state": "closed", "labels": []},
278 # Labels do not affect status, even if names collide.
279 {"id": 2, "state": "closed",
280 "labels": [{"name": "feature"}, {"name": "open"}]},
281 ]
282 tracker = GitHub("https://github.com/user/repository/issues")
283 with HTTMock(self._rate_limit_handler, self._issues_handler):
284 tracker.initializeRemoteBugDB(["1", "2"])
285 remote_status = tracker.getRemoteStatus("1")
286 self.assertEqual("closed", remote_status)
287 lp_status = tracker.convertRemoteStatus(remote_status)
288 self.assertEqual(BugTaskStatus.FIXRELEASED, lp_status)
289 remote_status = tracker.getRemoteStatus("2")
290 self.assertEqual("closed feature open", remote_status)
291 lp_status = tracker.convertRemoteStatus(remote_status)
292 self.assertEqual(BugTaskStatus.FIXRELEASED, lp_status)
293
294
295class TestGitHubUpdateBugWatches(TestCaseWithFactory):
296
297 layer = ZopelessDatabaseLayer
298
299 @urlmatch(path=r"^/rate_limit$")
300 def _rate_limit_handler(self, url, request):
301 self.rate_limit_request = request
302 rate_limit = {"limit": 5000, "remaining": 4000, "reset": 1000000000}
303 return {
304 "status_code": 200,
305 "content": {"resources": {"core": rate_limit}},
306 }
307
308 def test_process_one(self):
309 remote_bug = {"id": 1234, "state": "open", "labels": []}
310
311 @urlmatch(path=r".*/issues/1234$")
312 def handler(url, request):
313 return {"status_code": 200, "content": remote_bug}
314
315 bug = self.factory.makeBug()
316 bug_tracker = self.factory.makeBugTracker(
317 base_url="https://github.com/user/repository/issues",
318 bugtrackertype=BugTrackerType.GITHUB)
319 bug.addWatch(
320 bug_tracker, "1234", getUtility(ILaunchpadCelebrities).janitor)
321 self.assertEqual(
322 [("1234", None)],
323 [(watch.remotebug, watch.remotestatus)
324 for watch in bug_tracker.watches])
325 transaction.commit()
326 logger = BufferLogger()
327 bug_watch_updater = CheckwatchesMaster(transaction, logger=logger)
328 github = get_external_bugtracker(bug_tracker)
329 with HTTMock(self._rate_limit_handler, handler):
330 bug_watch_updater.updateBugWatches(github, bug_tracker.watches)
331 self.assertEqual(
332 "INFO Updating 1 watches for 1 bugs on "
333 "https://api.github.com/repos/user/repository\n",
334 logger.getLogBuffer())
335 self.assertEqual(
336 [("1234", BugTaskStatus.NEW)],
337 [(watch.remotebug, github.convertRemoteStatus(watch.remotestatus))
338 for watch in bug_tracker.watches])
339
340 def test_process_many(self):
341 remote_bugs = [
342 {"id": bug_id,
343 "state": "open" if (bug_id % 2) == 0 else "closed",
344 "labels": []}
345 for bug_id in range(1000, 1010)]
346
347 @urlmatch(path=r".*/issues$")
348 def handler(url, request):
349 return {"status_code": 200, "content": json.dumps(remote_bugs)}
350
351 bug = self.factory.makeBug()
352 bug_tracker = self.factory.makeBugTracker(
353 base_url="https://github.com/user/repository/issues",
354 bugtrackertype=BugTrackerType.GITHUB)
355 for remote_bug in remote_bugs:
356 bug.addWatch(
357 bug_tracker, str(remote_bug["id"]),
358 getUtility(ILaunchpadCelebrities).janitor)
359 transaction.commit()
360 logger = BufferLogger()
361 bug_watch_updater = CheckwatchesMaster(transaction, logger=logger)
362 github = get_external_bugtracker(bug_tracker)
363 with HTTMock(self._rate_limit_handler, handler):
364 bug_watch_updater.updateBugWatches(github, bug_tracker.watches)
365 self.assertEqual(
366 "INFO Updating 10 watches for 10 bugs on "
367 "https://api.github.com/repos/user/repository\n",
368 logger.getLogBuffer())
369 self.assertContentEqual(
370 [(str(bug_id), BugTaskStatus.NEW)
371 for bug_id in (1000, 1002, 1004, 1006, 1008)] +
372 [(str(bug_id), BugTaskStatus.FIXRELEASED)
373 for bug_id in (1001, 1003, 1005, 1007, 1009)],
374 [(watch.remotebug, github.convertRemoteStatus(watch.remotestatus))
375 for watch in bug_tracker.watches])
0376
=== modified file 'lib/lp/bugs/interfaces/bugtracker.py'
--- lib/lp/bugs/interfaces/bugtracker.py 2013-05-02 18:55:32 +0000
+++ lib/lp/bugs/interfaces/bugtracker.py 2016-07-13 10:12:00 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2013 Canonical Ltd. This software is licensed under the1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Bug tracker interfaces."""4"""Bug tracker interfaces."""
@@ -186,6 +186,12 @@
186 Google.186 Google.
187 """)187 """)
188188
189 GITHUB = DBItem(12, """
190 GitHub Issues
191
192 The issue tracker for projects hosted on GitHub.
193 """)
194
189195
190# A list of the BugTrackerTypes that don't need a remote product to be196# A list of the BugTrackerTypes that don't need a remote product to be
191# able to return a bug filing URL. We use a whitelist rather than a197# able to return a bug filing URL. We use a whitelist rather than a
@@ -193,6 +199,7 @@
193# a remote product is required. This saves us from presenting199# a remote product is required. This saves us from presenting
194# embarrassingly useless URLs to users.200# embarrassingly useless URLs to users.
195SINGLE_PRODUCT_BUGTRACKERTYPES = [201SINGLE_PRODUCT_BUGTRACKERTYPES = [
202 BugTrackerType.GITHUB,
196 BugTrackerType.GOOGLE_CODE,203 BugTrackerType.GOOGLE_CODE,
197 BugTrackerType.MANTIS,204 BugTrackerType.MANTIS,
198 BugTrackerType.PHPPROJECT,205 BugTrackerType.PHPPROJECT,
199206
=== modified file 'lib/lp/bugs/model/bugtracker.py'
--- lib/lp/bugs/model/bugtracker.py 2015-07-08 16:05:11 +0000
+++ lib/lp/bugs/model/bugtracker.py 2016-07-13 10:12:00 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2013 Canonical Ltd. This software is licensed under the1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -331,6 +331,8 @@
331 BugTrackerType.BUGZILLA: (331 BugTrackerType.BUGZILLA: (
332 "%(base_url)s/enter_bug.cgi?product=%(remote_product)s"332 "%(base_url)s/enter_bug.cgi?product=%(remote_product)s"
333 "&short_desc=%(summary)s&long_desc=%(description)s"),333 "&short_desc=%(summary)s&long_desc=%(description)s"),
334 BugTrackerType.GITHUB: (
335 "%(base_url)s/new?title=%(summary)s&body=%(description)s"),
334 BugTrackerType.GOOGLE_CODE: (336 BugTrackerType.GOOGLE_CODE: (
335 "%(base_url)s/entry?summary=%(summary)s&"337 "%(base_url)s/entry?summary=%(summary)s&"
336 "comment=%(description)s"),338 "comment=%(description)s"),
@@ -360,6 +362,9 @@
360 BugTrackerType.BUGZILLA: (362 BugTrackerType.BUGZILLA: (
361 "%(base_url)s/query.cgi?product=%(remote_product)s"363 "%(base_url)s/query.cgi?product=%(remote_product)s"
362 "&short_desc=%(summary)s"),364 "&short_desc=%(summary)s"),
365 BugTrackerType.GITHUB: (
366 "%(base_url)s?utf8=%%E2%%9C%%93"
367 "&q=is%%3Aissue%%20is%%3Aopen%%20%(summary)s"),
363 BugTrackerType.GOOGLE_CODE: "%(base_url)s/list?q=%(summary)s",368 BugTrackerType.GOOGLE_CODE: "%(base_url)s/list?q=%(summary)s",
364 BugTrackerType.DEBBUGS: (369 BugTrackerType.DEBBUGS: (
365 "%(base_url)s/cgi-bin/search.cgi?phrase=%(summary)s"370 "%(base_url)s/cgi-bin/search.cgi?phrase=%(summary)s"
366371
=== modified file 'lib/lp/bugs/model/bugwatch.py'
--- lib/lp/bugs/model/bugwatch.py 2015-07-08 16:05:11 +0000
+++ lib/lp/bugs/model/bugwatch.py 2016-07-13 10:12:00 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2012 Canonical Ltd. This software is licensed under the1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -81,6 +81,7 @@
81BUG_TRACKER_URL_FORMATS = {81BUG_TRACKER_URL_FORMATS = {
82 BugTrackerType.BUGZILLA: 'show_bug.cgi?id=%s',82 BugTrackerType.BUGZILLA: 'show_bug.cgi?id=%s',
83 BugTrackerType.DEBBUGS: 'cgi-bin/bugreport.cgi?bug=%s',83 BugTrackerType.DEBBUGS: 'cgi-bin/bugreport.cgi?bug=%s',
84 BugTrackerType.GITHUB: '%s',
84 BugTrackerType.GOOGLE_CODE: 'detail?id=%s',85 BugTrackerType.GOOGLE_CODE: 'detail?id=%s',
85 BugTrackerType.MANTIS: 'view.php?id=%s',86 BugTrackerType.MANTIS: 'view.php?id=%s',
86 BugTrackerType.ROUNDUP: 'issue%s',87 BugTrackerType.ROUNDUP: 'issue%s',
@@ -388,6 +389,7 @@
388 BugTrackerType.BUGZILLA: self.parseBugzillaURL,389 BugTrackerType.BUGZILLA: self.parseBugzillaURL,
389 BugTrackerType.DEBBUGS: self.parseDebbugsURL,390 BugTrackerType.DEBBUGS: self.parseDebbugsURL,
390 BugTrackerType.EMAILADDRESS: self.parseEmailAddressURL,391 BugTrackerType.EMAILADDRESS: self.parseEmailAddressURL,
392 BugTrackerType.GITHUB: self.parseGitHubURL,
391 BugTrackerType.GOOGLE_CODE: self.parseGoogleCodeURL,393 BugTrackerType.GOOGLE_CODE: self.parseGoogleCodeURL,
392 BugTrackerType.MANTIS: self.parseMantisURL,394 BugTrackerType.MANTIS: self.parseMantisURL,
393 BugTrackerType.PHPPROJECT: self.parsePHPProjectURL,395 BugTrackerType.PHPPROJECT: self.parsePHPProjectURL,
@@ -688,6 +690,18 @@
688 base_url = urlunsplit((scheme, host, tracker_path, '', ''))690 base_url = urlunsplit((scheme, host, tracker_path, '', ''))
689 return base_url, remote_bug691 return base_url, remote_bug
690692
693 def parseGitHubURL(self, scheme, host, path, query):
694 """Extract a GitHub Issues base URL and bug ID."""
695 if host != 'github.com':
696 return None
697 match = re.match(r'(.*/issues)/(\d+)$', path)
698 if not match:
699 return None
700 base_path = match.group(1)
701 remote_bug = match.group(2)
702 base_url = urlunsplit((scheme, host, base_path, '', ''))
703 return base_url, remote_bug
704
691 def extractBugTrackerAndBug(self, url):705 def extractBugTrackerAndBug(self, url):
692 """See `IBugWatchSet`."""706 """See `IBugWatchSet`."""
693 for trackertype, parse_func in (707 for trackertype, parse_func in (
694708
=== modified file 'lib/lp/bugs/stories/bugtracker/xx-bugtracker.txt'
--- lib/lp/bugs/stories/bugtracker/xx-bugtracker.txt 2015-06-27 04:10:49 +0000
+++ lib/lp/bugs/stories/bugtracker/xx-bugtracker.txt 2016-07-13 10:12:00 +0000
@@ -40,6 +40,7 @@
40 Savane40 Savane
41 PHP Project Bugtracker41 PHP Project Bugtracker
42 Google Code42 Google Code
43 GitHub Issues
4344
44The bug tracker name is used in URLs and certain characters (like '!')45The bug tracker name is used in URLs and certain characters (like '!')
45aren't allowed.46aren't allowed.
4647
=== modified file 'lib/lp/bugs/tests/test_bugwatch.py'
--- lib/lp/bugs/tests/test_bugwatch.py 2015-10-15 14:09:50 +0000
+++ lib/lp/bugs/tests/test_bugwatch.py 2016-07-13 10:12:00 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2012 Canonical Ltd. This software is licensed under the1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for BugWatchSet."""4"""Tests for BugWatchSet."""
@@ -10,12 +10,15 @@
10 timedelta,10 timedelta,
11 )11 )
12import re12import re
13import unittest
14from urlparse import urlunsplit13from urlparse import urlunsplit
1514
16from lazr.lifecycle.snapshot import Snapshot15from lazr.lifecycle.snapshot import Snapshot
17from pytz import utc16from pytz import utc
18from storm.store import Store17from storm.store import Store
18from testscenarios import (
19 load_tests_apply_scenarios,
20 WithScenarios,
21 )
19import transaction22import transaction
20from zope.component import getUtility23from zope.component import getUtility
21from zope.security.interfaces import Unauthorized24from zope.security.interfaces import Unauthorized
@@ -50,6 +53,7 @@
50 ANONYMOUS,53 ANONYMOUS,
51 login,54 login,
52 login_person,55 login_person,
56 TestCase,
53 TestCaseWithFactory,57 TestCaseWithFactory,
54 )58 )
55from lp.testing.dbuser import switch_dbuser59from lp.testing.dbuser import switch_dbuser
@@ -61,8 +65,111 @@
61from lp.testing.sampledata import ADMIN_EMAIL65from lp.testing.sampledata import ADMIN_EMAIL
6266
6367
64class ExtractBugTrackerAndBugTestBase:68class ExtractBugTrackerAndBugTest(WithScenarios, TestCase):
65 """Test base for testing BugWatchSet.extractBugTrackerAndBug."""69 """Test BugWatchSet.extractBugTrackerAndBug."""
70
71 scenarios = [
72 ('Mantis', {
73 'bugtracker_type': BugTrackerType.MANTIS,
74 'bug_url': 'http://some.host/bugs/view.php?id=3224',
75 'base_url': 'http://some.host/bugs/',
76 'bug_id': '3224',
77 }),
78 ('Bugzilla', {
79 'bugtracker_type': BugTrackerType.BUGZILLA,
80 'bug_url': 'http://some.host/bugs/show_bug.cgi?id=3224',
81 'base_url': 'http://some.host/bugs/',
82 'bug_id': '3224',
83 }),
84 # Issuezilla is practically the same as Bugzilla, so we treat it as
85 # a normal BUGZILLA type.
86 ('Issuezilla', {
87 'bugtracker_type': BugTrackerType.BUGZILLA,
88 'bug_url': 'http://some.host/bugs/show_bug.cgi?issue=3224',
89 'base_url': 'http://some.host/bugs/',
90 'bug_id': '3224',
91 }),
92 ('RoundUp', {
93 'bugtracker_type': BugTrackerType.ROUNDUP,
94 'bug_url': 'http://some.host/some/path/issue377',
95 'base_url': 'http://some.host/some/path/',
96 'bug_id': '377',
97 }),
98 ('Trac', {
99 'bugtracker_type': BugTrackerType.TRAC,
100 'bug_url': 'http://some.host/some/path/ticket/42',
101 'base_url': 'http://some.host/some/path/',
102 'bug_id': '42',
103 }),
104 ('Debbugs', {
105 'bugtracker_type': BugTrackerType.DEBBUGS,
106 'bug_url': (
107 'http://some.host/some/path/cgi-bin/bugreport.cgi?bug=42'),
108 'base_url': 'http://some.host/some/path/',
109 'bug_id': '42',
110 }),
111 ('DebbugsShorthand', {
112 'bugtracker_type': BugTrackerType.DEBBUGS,
113 'bug_url': 'http://bugs.debian.org/42',
114 'base_url': 'http://bugs.debian.org/',
115 'bug_id': '42',
116 'already_registered': True,
117 }),
118 # SourceForge-like URLs, though not actually SourceForge itself.
119 ('XForge', {
120 'bugtracker_type': BugTrackerType.SOURCEFORGE,
121 'bug_url': (
122 'http://gforge.example.com/tracker/index.php'
123 '?func=detail&aid=90812&group_id=84122&atid=575154'),
124 'base_url': 'http://gforge.example.com/',
125 'bug_id': '90812',
126 }),
127 ('RT', {
128 'bugtracker_type': BugTrackerType.RT,
129 'bug_url': 'http://some.host/Ticket/Display.html?id=2379',
130 'base_url': 'http://some.host/',
131 'bug_id': '2379',
132 }),
133 ('CPAN', {
134 'bugtracker_type': BugTrackerType.RT,
135 'bug_url': 'http://rt.cpan.org/Public/Bug/Display.html?id=2379',
136 'base_url': 'http://rt.cpan.org/',
137 'bug_id': '2379',
138 }),
139 ('Savannah', {
140 'bugtracker_type': BugTrackerType.SAVANE,
141 'bug_url': 'http://savannah.gnu.org/bugs/?22003',
142 'base_url': 'http://savannah.gnu.org/',
143 'bug_id': '22003',
144 'already_registered': True,
145 }),
146 ('Savane', {
147 'bugtracker_type': BugTrackerType.SAVANE,
148 'bug_url': 'http://savane.example.com/bugs/?12345',
149 'base_url': 'http://savane.example.com/',
150 'bug_id': '12345',
151 }),
152 ('PHPProject', {
153 'bugtracker_type': BugTrackerType.PHPPROJECT,
154 'bug_url': 'http://phptracker.example.com/bug.php?id=12345',
155 'base_url': 'http://phptracker.example.com/',
156 'bug_id': '12345',
157 }),
158 ('GoogleCode', {
159 'bugtracker_type': BugTrackerType.GOOGLE_CODE,
160 'bug_url': (
161 'http://code.google.com/p/myproject/issues/detail?id=12345'),
162 'base_url': 'http://code.google.com/p/myproject/issues',
163 'bug_id': '12345',
164 }),
165 ('GitHub', {
166 'bugtracker_type': BugTrackerType.GITHUB,
167 'bug_url': 'https://github.com/user/repository/issues/12345',
168 'base_url': 'https://github.com/user/repository/issues',
169 'bug_id': '12345',
170 }),
171 ]
172
66 layer = LaunchpadFunctionalLayer173 layer = LaunchpadFunctionalLayer
67174
68 # A URL to an unregistered bug tracker.175 # A URL to an unregistered bug tracker.
@@ -77,7 +184,11 @@
77 # The bug id in the sample bug_url.184 # The bug id in the sample bug_url.
78 bug_id = None185 bug_id = None
79186
187 # True if the bug tracker is already registered in sampledata.
188 already_registered = False
189
80 def setUp(self):190 def setUp(self):
191 super(ExtractBugTrackerAndBugTest, self).setUp()
81 login(ANONYMOUS)192 login(ANONYMOUS)
82 self.bugwatch_set = getUtility(IBugWatchSet)193 self.bugwatch_set = getUtility(IBugWatchSet)
83 self.bugtracker_set = getUtility(IBugTrackerSet)194 self.bugtracker_set = getUtility(IBugTrackerSet)
@@ -108,8 +219,9 @@
108 # A NoBugTrackerFound exception is raised if extractBugTrackerAndBug219 # A NoBugTrackerFound exception is raised if extractBugTrackerAndBug
109 # can extract a base URL and bug id from the URL but there's no220 # can extract a base URL and bug id from the URL but there's no
110 # such bug tracker registered in Launchpad.221 # such bug tracker registered in Launchpad.
111 self.failUnless(222 if self.already_registered:
112 self.bugtracker_set.queryByBaseURL(self.base_url) is None)223 return
224 self.assertIsNone(self.bugtracker_set.queryByBaseURL(self.base_url))
113 try:225 try:
114 bugtracker, bug = self.bugwatch_set.extractBugTrackerAndBug(226 bugtracker, bug = self.bugwatch_set.extractBugTrackerAndBug(
115 self.bug_url)227 self.bug_url)
@@ -132,86 +244,7 @@
132 self.bugwatch_set.extractBugTrackerAndBug, invalid_url)244 self.bugwatch_set.extractBugTrackerAndBug, invalid_url)
133245
134246
135class MantisExtractBugTrackerAndBugTest(247class SFExtractBugTrackerAndBugTest(ExtractBugTrackerAndBugTest):
136 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
137 """Ensure BugWatchSet.extractBugTrackerAndBug works with Mantis URLs."""
138
139 bugtracker_type = BugTrackerType.MANTIS
140 bug_url = 'http://some.host/bugs/view.php?id=3224'
141 base_url = 'http://some.host/bugs/'
142 bug_id = '3224'
143
144
145class BugzillaExtractBugTrackerAndBugTest(
146 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
147 """Ensure BugWatchSet.extractBugTrackerAndBug works with Bugzilla URLs."""
148
149 bugtracker_type = BugTrackerType.BUGZILLA
150 bug_url = 'http://some.host/bugs/show_bug.cgi?id=3224'
151 base_url = 'http://some.host/bugs/'
152 bug_id = '3224'
153
154
155class IssuezillaExtractBugTrackerAndBugTest(
156 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
157 """Ensure BugWatchSet.extractBugTrackerAndBug works with Issuezilla.
158
159 Issuezilla is practically the same as Buzilla, so we treat it as a
160 normal BUGZILLA type.
161 """
162
163 bugtracker_type = BugTrackerType.BUGZILLA
164 bug_url = 'http://some.host/bugs/show_bug.cgi?issue=3224'
165 base_url = 'http://some.host/bugs/'
166 bug_id = '3224'
167
168
169class RoundUpExtractBugTrackerAndBugTest(
170 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
171 """Ensure BugWatchSet.extractBugTrackerAndBug works with RoundUp URLs."""
172
173 bugtracker_type = BugTrackerType.ROUNDUP
174 bug_url = 'http://some.host/some/path/issue377'
175 base_url = 'http://some.host/some/path/'
176 bug_id = '377'
177
178
179class TracExtractBugTrackerAndBugTest(
180 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
181 """Ensure BugWatchSet.extractBugTrackerAndBug works with Trac URLs."""
182
183 bugtracker_type = BugTrackerType.TRAC
184 bug_url = 'http://some.host/some/path/ticket/42'
185 base_url = 'http://some.host/some/path/'
186 bug_id = '42'
187
188
189class DebbugsExtractBugTrackerAndBugTest(
190 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
191 """Ensure BugWatchSet.extractBugTrackerAndBug works with Debbugs URLs."""
192
193 bugtracker_type = BugTrackerType.DEBBUGS
194 bug_url = 'http://some.host/some/path/cgi-bin/bugreport.cgi?bug=42'
195 base_url = 'http://some.host/some/path/'
196 bug_id = '42'
197
198
199class DebbugsExtractBugTrackerAndBugShorthandTest(
200 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
201 """Ensure extractBugTrackerAndBug works for short Debbugs URLs."""
202
203 bugtracker_type = BugTrackerType.DEBBUGS
204 bug_url = 'http://bugs.debian.org/42'
205 base_url = 'http://bugs.debian.org/'
206 bug_id = '42'
207
208 def test_unregistered_tracker_url(self):
209 # bugs.debian.org is already registered, so no dice.
210 pass
211
212
213class SFExtractBugTrackerAndBugTest(
214 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
215 """Ensure BugWatchSet.extractBugTrackerAndBug works with SF URLs.248 """Ensure BugWatchSet.extractBugTrackerAndBug works with SF URLs.
216249
217 We have only one SourceForge tracker registered in Launchpad, so we250 We have only one SourceForge tracker registered in Launchpad, so we
@@ -219,17 +252,31 @@
219 bug id.252 bug id.
220 """253 """
221254
222 bugtracker_type = BugTrackerType.SOURCEFORGE255 scenarios = [
223 bug_url = (256 # We have only one SourceForge tracker registered in Launchpad, so
224 'http://sourceforge.net/tracker/index.php'257 # we don't care about the aid and group_id, only about atid which is
225 '?func=detail&aid=1568562&group_id=84122&atid=575154')258 # the bug id.
226 base_url = 'http://sourceforge.net/'259 ('SourceForge', {
227 bug_id = '1568562'260 'bugtracker_type': BugTrackerType.SOURCEFORGE,
261 'bug_url': (
262 'http://sourceforge.net/tracker/index.php'
263 '?func=detail&aid=1568562&group_id=84122&atid=575154'),
264 'base_url': 'http://sourceforge.net/',
265 'bug_id': '1568562',
266 }),
267 # New SF tracker URLs.
268 ('SourceForgeTracker2', {
269 'bugtracker_type': BugTrackerType.SOURCEFORGE,
270 'bug_url': (
271 'http://sourceforge.net/tracker2/'
272 '?func=detail&aid=1568562&group_id=84122&atid=575154'),
273 'base_url': 'http://sourceforge.net/',
274 'bug_id': '1568562',
275 }),
276 ]
228277
229 def test_unregistered_tracker_url(self):278 # The SourceForge tracker is always registered.
230 # The SourceForge tracker is always registered, so this test279 already_registered = True
231 # doesn't make sense for SourceForge URLs.
232 pass
233280
234 def test_aliases(self):281 def test_aliases(self):
235 """Test that parsing SourceForge URLs works with the SF aliases."""282 """Test that parsing SourceForge URLs works with the SF aliases."""
@@ -260,82 +307,11 @@
260 self.base_url = original_base_url307 self.base_url = original_base_url
261308
262309
263class SFTracker2ExtractBugTrackerAndBugTest(SFExtractBugTrackerAndBugTest):310class EmailAddressExtractBugTrackerAndBugTest(ExtractBugTrackerAndBugTest):
264 """Ensure extractBugTrackerAndBug works for new SF tracker URLs."""
265
266 bugtracker_type = BugTrackerType.SOURCEFORGE
267 bug_url = (
268 'http://sourceforge.net/tracker2/'
269 '?func=detail&aid=1568562&group_id=84122&atid=575154')
270 base_url = 'http://sourceforge.net/'
271 bug_id = '1568562'
272
273
274class XForgeExtractBugTrackerAndBugTest(
275 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
276 """Ensure extractBugTrackerAndBug works with SourceForge-like URLs.
277 """
278
279 bugtracker_type = BugTrackerType.SOURCEFORGE
280 bug_url = (
281 'http://gforge.example.com/tracker/index.php'
282 '?func=detail&aid=90812&group_id=84122&atid=575154')
283 base_url = 'http://gforge.example.com/'
284 bug_id = '90812'
285
286
287class RTExtractBugTrackerAndBugTest(
288 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
289 """Ensure BugWatchSet.extractBugTrackerAndBug works with RT URLs."""
290
291 bugtracker_type = BugTrackerType.RT
292 bug_url = 'http://some.host/Ticket/Display.html?id=2379'
293 base_url = 'http://some.host/'
294 bug_id = '2379'
295
296
297class CpanExtractBugTrackerAndBugTest(
298 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
299 """Ensure BugWatchSet.extractBugTrackerAndBug works with CPAN URLs."""
300
301 bugtracker_type = BugTrackerType.RT
302 bug_url = 'http://rt.cpan.org/Public/Bug/Display.html?id=2379'
303 base_url = 'http://rt.cpan.org/'
304 bug_id = '2379'
305
306
307class SavannahExtractBugTrackerAndBugTest(
308 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
309 """Ensure BugWatchSet.extractBugTrackerAndBug works with Savannah URLs.
310 """
311
312 bugtracker_type = BugTrackerType.SAVANE
313 bug_url = 'http://savannah.gnu.org/bugs/?22003'
314 base_url = 'http://savannah.gnu.org/'
315 bug_id = '22003'
316
317 def test_unregistered_tracker_url(self):
318 # The Savannah tracker is always registered, so this test
319 # doesn't make sense for Savannah URLs.
320 pass
321
322
323class SavaneExtractBugTrackerAndBugTest(
324 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
325 """Ensure BugWatchSet.extractBugTrackerAndBug works with Savane URLs.
326 """
327
328 bugtracker_type = BugTrackerType.SAVANE
329 bug_url = 'http://savane.example.com/bugs/?12345'
330 base_url = 'http://savane.example.com/'
331 bug_id = '12345'
332
333
334class EmailAddressExtractBugTrackerAndBugTest(
335 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
336 """Ensure BugWatchSet.extractBugTrackerAndBug works with email addresses.311 """Ensure BugWatchSet.extractBugTrackerAndBug works with email addresses.
337 """312 """
338313
314 scenarios = None
339 bugtracker_type = BugTrackerType.EMAILADDRESS315 bugtracker_type = BugTrackerType.EMAILADDRESS
340 bug_url = 'mailto:foo.bar@example.com'316 bug_url = 'mailto:foo.bar@example.com'
341 base_url = 'mailto:foo.bar@example.com'317 base_url = 'mailto:foo.bar@example.com'
@@ -353,28 +329,6 @@
353 pass329 pass
354330
355331
356class PHPProjectBugTrackerExtractBugTrackerAndBugTest(
357 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
358 """Ensure BugWatchSet.extractBugTrackerAndBug works with PHP bug URLs.
359 """
360
361 bugtracker_type = BugTrackerType.PHPPROJECT
362 bug_url = 'http://phptracker.example.com/bug.php?id=12345'
363 base_url = 'http://phptracker.example.com/'
364 bug_id = '12345'
365
366
367class GoogleCodeBugTrackerExtractBugTrackerAndBugTest(
368 ExtractBugTrackerAndBugTestBase, unittest.TestCase):
369 """Ensure BugWatchSet.extractBugTrackerAndBug works for Google Code URLs.
370 """
371
372 bugtracker_type = BugTrackerType.GOOGLE_CODE
373 bug_url = 'http://code.google.com/p/myproject/issues/detail?id=12345'
374 base_url = 'http://code.google.com/p/myproject/issues'
375 bug_id = '12345'
376
377
378class TestBugWatch(TestCaseWithFactory):332class TestBugWatch(TestCaseWithFactory):
379333
380 layer = LaunchpadZopelessLayer334 layer = LaunchpadZopelessLayer
@@ -758,3 +712,6 @@
758 login_person(lp_dev)712 login_person(lp_dev)
759 self.bug_watch.reset()713 self.bug_watch.reset()
760 self._assertBugWatchHasBeenChanged()714 self._assertBugWatchHasBeenChanged()
715
716
717load_tests = load_tests_apply_scenarios