Merge lp:~leonardr/launchpadlib/improve-workflow into lp:launchpadlib

Proposed by Leonard Richardson
Status: Merged
Approved by: Martin Pool
Approved revision: 97
Merged at revision: 93
Proposed branch: lp:~leonardr/launchpadlib/improve-workflow
Merge into: lp:launchpadlib
Diff against target: 268 lines (+68/-32)
4 files modified
src/launchpadlib/NEWS.txt (+9/-0)
src/launchpadlib/credentials.py (+36/-13)
src/launchpadlib/launchpad.py (+12/-8)
src/launchpadlib/tests/test_launchpad.py (+11/-11)
To merge this branch: bzr merge lp:~leonardr/launchpadlib/improve-workflow
Reviewer Review Type Date Requested Status
Martin Pool (community) Approve
Review via email: mp+29849@code.launchpad.net

Description of the change

This branch improves the console-based workflow for exchanging a request token for an access token.

Here's the old workflow:
1. Tab into your browser app.
2. Authorize (or don't) the launchpadlib app.
3. Tab into the console.
4. Hit Enter.

My original plan was to eliminate all but step 2. If I could somehow do this, the current launchpadlib workflow would be more or less the same as the proposed expensive workflow involving a custom desktop application and a brand new OAuth access level.

Unfortunately, a combination of problems with the webbrowser module/Firefox/the standard Ubuntu window manager foiled my plan to eliminate step 1, and Javascript security issues foiled my plan to eliminate step 3.

But, I was able to eliminate step 4, with a minor change to Launchpad (https://code.edge.launchpad.net/~leonardr/launchpad/distinguish-between-unauthorized-and-forbidden). This branch eliminates the "hit Enter" step by periodically polling Launchpad, attempting to exchange the request token for an access token. If the user has decided to authorize the app, this request returns a 200 response code with the access token information. If the user has declined to authorize the app, this request returns a 403 response code (this is the minor change to Launchpad). If the user has yet to make a decision, this request returns a 401 response code.

Since it's now possible to distinguish "I don't want you to have access to my Launchpad account" from "I haven't made a decision about giving you access", I took the opportunity to improve launchpadlib's behavior when access is denied. Previously launchpadlib just raised an unhelpful HTTPError. Now it ensures that login_with() and the like return None instead of a Launchpad object. When you call login_with(), you can check the return value and know that if it's None, the user made a decision not to give you access--you can sys.exit() or complain or whatever.

Bonus: since the console-based workflow no longer reads from stdin, it should be now possible to use it from a GUI application.

Since this branch only works on a version of Launchpad that has my branch installed, I won't be releasing a version of launchpadlib that includes this branch until my Launchpad branch is fully deployed.

To post a comment you must log in.
Revision history for this message
Martin Pool (mbp) wrote :

Nice cover letter.

> Since it's now possible to distinguish "I don't want you to have access to my Launchpad account" from "I haven't made a decision about giving you access", I took the opportunity to improve launchpadlib's behavior when access is denied. Previously launchpadlib just raised an unhelpful HTTPError. Now it ensures that login_with() and the like return None instead of a Launchpad object. When you call login_with(), you can check the return value and know that if it's None, the user made a decision not to give you access--you can sys.exit() or complain or whatever.

This of course needs to go into the docs and I would say ideally to get a brief post on the blog (even just a pointer to this mp.)

I think you should raise a specific exception if the user denies access, rather than returning None:

1- If the app ignores the return code, we don't want it to just blithely continue and then cause knock-on errors. The default should be for the app to stop.
2- Returning None for errors is just bad.
3- It gives you a space to eventually possibly pass back a sensible message. (I mean I doubt the user wants to explain why, but perhaps you can also in the future say "this app is banned" or "you're creating too many tokens" etc.) I would say you should probably put the error body in to the exception, even if at the moment it's not super helpful.

+ print " (%s)" % self.authorization_url
+ print "should be opening in your browser. Use your browser to"
+ print "to authorize this program to access Launchpad on your behalf."

It would be nice to not directly print but rather pass this to a callback function so guis can hook it. It could be a followon but perhaps you could just hoist it out while you're here.

- launchpad.credentials.save_to_path(consumer_credentials_path)
- os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)
+ if launchpad is not None:
+ launchpad.credentials.save_to_path(consumer_credentials_path)
+ os.chmod(
+ consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)

It's always much more secure to create the path secure (by passing a third parameter to os.open) than to chmod it later: the app may be interrupted, someone may grab it in the race window, etc.

Otherwise looks good.

review: Needs Fixing
Revision history for this message
James Westby (james-w) wrote :

Hi,

One trick I have seen that is a bit nicer than polling is to pass a localhost
url as the OAuth callback, and then set up a listening socket at that URL, and
respond to any requests made to it by checking the status of the token.

It's not that much different really, but gives you a more responsive application
for users without hitting LP frequently.

Thanks,

James

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

I responded to your comments and also changed the code that creates directories so that they're chmod 0700. Take another look.

94. By Leonard Richardson

Response to feedback.

Revision history for this message
Robert Collins (lifeless) wrote :

+1 on all of poolies points.

James - the local socket thing is very fragile. better to use long
poll on LP to have lp signal when its ready.

-Rob

Revision history for this message
Martin Pool (mbp) wrote :

+ self.output(self.WAITING_FOR_USER % self.authorization_url)
+ while credentials.access_token is None:
+ try:
+ credentials.exchange_request_token_for_access_token(web_root)
+ except HTTPError, e:
+ if e.response.status == 401:
+ # The user has not made their decision yet.
+ time.sleep(1)
+ elif e.response.status == 403:
+ # The user decided not to authorize this
+ # application.
+ raise EndUserDeclinedAuthorization(e.content)
+ else:
+ # The user decided to grant access.
+ # credentials.access_token is no longer None.
+ break

This treats a 500 error as deciding to grant access. Really? I think you want the 'break' inside the try block. Aside from that it looks good.

 review: tweak

I do wonder a bit about the load this will introduce but perhaps we can measure that. Can we find out from the logs how many times this is called at the moment?

review: Needs Fixing
95. By Leonard Richardson

Response to feedback.

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

Ok, take another look.

1. I made the poll time configurable with a module constant, and moved the sleep() call to before the request so it will always be called once. Since it would take superhuman reflexes to click a link before the sleep() call expires (for any reasonable value of the poll time), this will save at least one HTTP request every time.

2. If Launchpad goes down during the poll, the poll will continue instead of the program assuming the user granted access. (I tested this by killing launchpad.dev after opening token-authorized in my browser.)

3. I saw only 50 requests for +access-token for July 16th (a Friday), out of about 4.5 million requests total. Even a large number of additional POSTs should go unnoticed. But I'd be OK with increasing the poll time to 2 or 3 seconds.

Revision history for this message
Martin Pool (mbp) wrote :

+ else:
+ # The user has not made their decision yet,
+ # or there was an error accessing the server.
+ pass

So this seems to me to mean that if the server is giving some non-403 error, the client will silently keep polling indefinitely. I think that is a bit strange. I can see how you might want to keep polling even if you get a transient error, but I think you should at least print the error. That's what I meant in my comment earlier today.

btw I suggest if you're making substantive changes is response to feedback you actually describe them in the commit message. If someone annotates the code later just "response to feedback" doesn't explain a lot.

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

All right, I've changed the code so that if there's a problem communicating with Launchpad after the initial browser open, the exception will be printed to stdout.

96. By Leonard Richardson

If there's a problem communicating with Launchpad, print out the exception before retrying.

97. By Leonard Richardson

Merge with trunk.

Revision history for this message
Martin Pool (mbp) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/launchpadlib/NEWS.txt'
--- src/launchpadlib/NEWS.txt 2010-07-19 18:38:21 +0000
+++ src/launchpadlib/NEWS.txt 2010-07-20 15:57:51 +0000
@@ -5,6 +5,15 @@
51.6.3 (Unreleased)51.6.3 (Unreleased)
6==================6==================
77
8- Instead of making the end-user hit Enter after authorizing an
9 application to access their Launchpad account, launchpadlib will
10 automatically poll Launchpad until the user makes a decision.
11
12- Previously, if the end-user explicitly denied access to a
13 launchpadlib application, launchpadlib raised an unhelpful
14 exception. Now, methods like login_with() will return None instead
15 of a Launchpad object.
16
8- Improved the XSLT stylesheet to reflect Launchpad's more complex17- Improved the XSLT stylesheet to reflect Launchpad's more complex
9 top-level structure. [bug=286941]18 top-level structure. [bug=286941]
1019
1120
=== modified file 'src/launchpadlib/credentials.py'
--- src/launchpadlib/credentials.py 2010-03-16 21:05:15 +0000
+++ src/launchpadlib/credentials.py 2010-07-20 15:57:51 +0000
@@ -30,6 +30,7 @@
30import httplib230import httplib2
31import sys31import sys
32import textwrap32import textwrap
33import time
33from urllib import urlencode, quote34from urllib import urlencode, quote
34from urlparse import urljoin35from urlparse import urljoin
35import webbrowser36import webbrowser
@@ -45,6 +46,7 @@
45request_token_page = '+request-token'46request_token_page = '+request-token'
46access_token_page = '+access-token'47access_token_page = '+access-token'
47authorize_token_page = '+authorize-token'48authorize_token_page = '+authorize-token'
49access_token_poll_time = 1
4850
4951
50class Credentials(OAuthAuthorizer):52class Credentials(OAuthAuthorizer):
@@ -512,6 +514,8 @@
512 themselves.514 themselves.
513 """515 """
514516
517 WAITING_FOR_USER = "The authorization page:\n (%s)\nshould be opening in your browser. Use your browser to authorize\nthis program to access Launchpad on your behalf. \n\nWaiting to hear from Launchpad about your decision..."
518
515 def __init__(self, web_root, consumer_name, request_token,519 def __init__(self, web_root, consumer_name, request_token,
516 allow_access_levels=[], max_failed_attempts=3):520 allow_access_levels=[], max_failed_attempts=3):
517 web_root = uris.lookup_web_root(web_root)521 web_root = uris.lookup_web_root(web_root)
@@ -525,20 +529,35 @@
525 web_root, consumer_name, request_token,529 web_root, consumer_name, request_token,
526 allow_access_levels, max_failed_attempts)530 allow_access_levels, max_failed_attempts)
527531
528 def __call__(self):532 def output(self, message):
533 """Display a message.
534
535 By default, prints the message to standard output. The message
536 does not require any user interaction--it's solely
537 informative.
538 """
539 print message
540
541 def __call__(self, credentials, web_root):
529 self.open_page_in_user_browser(self.authorization_url)542 self.open_page_in_user_browser(self.authorization_url)
530 print "The authorization page:"543 self.output(self.WAITING_FOR_USER % self.authorization_url)
531 print " (%s)" % self.authorization_url544 while credentials.access_token is None:
532 print "should be opening in your browser. After you have authorized"545 time.sleep(access_token_poll_time)
533 print "this program to access Launchpad on your behalf you should come"546 try:
534 print ("back here and press <Enter> to finish the authentication "547 credentials.exchange_request_token_for_access_token(web_root)
535 "process.")548 break
536 self.wait_for_request_token_authorization()549 except HTTPError, e:
537550 if e.response.status == 403:
538 def wait_for_request_token_authorization(self):551 # The user decided not to authorize this
539 """Get the end-user to hit enter."""552 # application.
540 sys.stdin.readline()553 raise EndUserDeclinedAuthorization(e.content)
541554 elif e.response.status == 401:
555 # The user has not made a decision yet.
556 pass
557 else:
558 # There was an error accessing the server.
559 print "Unexpected response from Launchpad:"
560 print e
542561
543class TokenAuthorizationException(Exception):562class TokenAuthorizationException(Exception):
544 pass563 pass
@@ -548,6 +567,10 @@
548 pass567 pass
549568
550569
570class EndUserDeclinedAuthorization(TokenAuthorizationException):
571 pass
572
573
551class ClientError(TokenAuthorizationException):574class ClientError(TokenAuthorizationException):
552 pass575 pass
553576
554577
=== modified file 'src/launchpadlib/launchpad.py'
--- src/launchpadlib/launchpad.py 2010-06-21 15:16:28 +0000
+++ src/launchpadlib/launchpad.py 2010-07-20 15:57:51 +0000
@@ -210,8 +210,10 @@
210 web_root, authorization_json['oauth_token_consumer'],210 web_root, authorization_json['oauth_token_consumer'],
211 authorization_json['oauth_token'], allow_access_levels,211 authorization_json['oauth_token'], allow_access_levels,
212 max_failed_attempts)212 max_failed_attempts)
213 authorizer()213 authorizer(credentials, web_root)
214 credentials.exchange_request_token_for_access_token(web_root)214 if credentials.access_token is None:
215 # The end-user refused to authorize the application.
216 return None
215 return cls(credentials, service_root, cache, timeout, proxy_info,217 return cls(credentials, service_root, cache, timeout, proxy_info,
216 version)218 version)
217219
@@ -273,7 +275,7 @@
273 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)275 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
274 credentials_path = os.path.join(service_root_dir, 'credentials')276 credentials_path = os.path.join(service_root_dir, 'credentials')
275 if not os.path.exists(credentials_path):277 if not os.path.exists(credentials_path):
276 os.makedirs(credentials_path)278 os.makedirs(credentials_path, 0700)
277 if credentials_file is None:279 if credentials_file is None:
278 consumer_credentials_path = os.path.join(credentials_path,280 consumer_credentials_path = os.path.join(credentials_path,
279 consumer_name)281 consumer_name)
@@ -292,8 +294,10 @@
292 authorizer_class=authorizer_class,294 authorizer_class=authorizer_class,
293 allow_access_levels=allow_access_levels,295 allow_access_levels=allow_access_levels,
294 max_failed_attempts=max_failed_attempts, version=version)296 max_failed_attempts=max_failed_attempts, version=version)
295 launchpad.credentials.save_to_path(consumer_credentials_path)297 if launchpad is not None:
296 os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)298 launchpad.credentials.save_to_path(consumer_credentials_path)
299 os.chmod(
300 consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)
297 return launchpad301 return launchpad
298302
299 @classmethod303 @classmethod
@@ -320,8 +324,8 @@
320 launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')324 launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')
321 launchpadlib_dir = os.path.expanduser(launchpadlib_dir)325 launchpadlib_dir = os.path.expanduser(launchpadlib_dir)
322 if not os.path.exists(launchpadlib_dir):326 if not os.path.exists(launchpadlib_dir):
323 os.makedirs(launchpadlib_dir,0700)327 os.makedirs(launchpadlib_dir, 0700)
324 os.chmod(launchpadlib_dir,0700)328 os.chmod(launchpadlib_dir, 0700)
325 # Determine the real service root.329 # Determine the real service root.
326 service_root = uris.lookup_service_root(service_root)330 service_root = uris.lookup_service_root(service_root)
327 # Each service root has its own cache and credential dirs.331 # Each service root has its own cache and credential dirs.
@@ -330,5 +334,5 @@
330 service_root_dir = os.path.join(launchpadlib_dir, host_name)334 service_root_dir = os.path.join(launchpadlib_dir, host_name)
331 cache_path = os.path.join(service_root_dir, 'cache')335 cache_path = os.path.join(service_root_dir, 'cache')
332 if not os.path.exists(cache_path):336 if not os.path.exists(cache_path):
333 os.makedirs(cache_path)337 os.makedirs(cache_path, 0700)
334 return (service_root, launchpadlib_dir, cache_path, service_root_dir)338 return (service_root, launchpadlib_dir, cache_path, service_root_dir)
335339
=== modified file 'src/launchpadlib/tests/test_launchpad.py'
--- src/launchpadlib/tests/test_launchpad.py 2010-07-15 00:49:57 +0000
+++ src/launchpadlib/tests/test_launchpad.py 2010-07-20 15:57:51 +0000
@@ -150,7 +150,7 @@
150 # cache and credentials for all service roots are stored.150 # cache and credentials for all service roots are stored.
151 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')151 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
152 launchpad = NoNetworkLaunchpad.login_with(152 launchpad = NoNetworkLaunchpad.login_with(
153 'not important', service_root='http://api.example.com/beta',153 'not important', service_root='http://api.example.com/',
154 launchpadlib_dir=launchpadlib_dir)154 launchpadlib_dir=launchpadlib_dir)
155 # The 'launchpadlib' dir got created.155 # The 'launchpadlib' dir got created.
156 self.assertTrue(os.path.isdir(launchpadlib_dir))156 self.assertTrue(os.path.isdir(launchpadlib_dir))
@@ -174,7 +174,7 @@
174 mode = stat.S_IMODE(statinfo.st_mode)174 mode = stat.S_IMODE(statinfo.st_mode)
175 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)175 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
176 launchpad = NoNetworkLaunchpad.login_with(176 launchpad = NoNetworkLaunchpad.login_with(
177 'not important', service_root='http://api.example.com/beta',177 'not important', service_root='http://api.example.com/',
178 launchpadlib_dir=launchpadlib_dir)178 launchpadlib_dir=launchpadlib_dir)
179 # Verify the mode has been changed to 0700179 # Verify the mode has been changed to 0700
180 statinfo = os.stat(launchpadlib_dir)180 statinfo = os.stat(launchpadlib_dir)
@@ -184,7 +184,7 @@
184 def test_dirs_created_are_secure(self):184 def test_dirs_created_are_secure(self):
185 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')185 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
186 launchpad = NoNetworkLaunchpad.login_with(186 launchpad = NoNetworkLaunchpad.login_with(
187 'not important', service_root='http://api.example.com/beta',187 'not important', service_root='http://api.example.com/',
188 launchpadlib_dir=launchpadlib_dir)188 launchpadlib_dir=launchpadlib_dir)
189 self.assertTrue(os.path.isdir(launchpadlib_dir))189 self.assertTrue(os.path.isdir(launchpadlib_dir))
190 # Verify the mode is safe190 # Verify the mode is safe
@@ -213,7 +213,7 @@
213213
214 def test_no_credentials_calls_get_token_and_login(self):214 def test_no_credentials_calls_get_token_and_login(self):
215 # If no credentials are found, get_token_and_login() is called.215 # If no credentials are found, get_token_and_login() is called.
216 service_root = 'http://api.example.com/beta'216 service_root = 'http://api.example.com/'
217 timeout = object()217 timeout = object()
218 proxy_info = object()218 proxy_info = object()
219 launchpad = NoNetworkLaunchpad.login_with(219 launchpad = NoNetworkLaunchpad.login_with(
@@ -235,7 +235,7 @@
235 """Test the anonymous login helper function."""235 """Test the anonymous login helper function."""
236 launchpad = NoNetworkLaunchpad.login_anonymously(236 launchpad = NoNetworkLaunchpad.login_anonymously(
237 'anonymous access', launchpadlib_dir=self.temp_dir,237 'anonymous access', launchpadlib_dir=self.temp_dir,
238 service_root='http://api.example.com/beta')238 service_root='http://api.example.com/')
239 self.assertEqual(launchpad.credentials.access_token.key, '')239 self.assertEqual(launchpad.credentials.access_token.key, '')
240 self.assertEqual(launchpad.credentials.access_token.secret, '')240 self.assertEqual(launchpad.credentials.access_token.secret, '')
241241
@@ -250,7 +250,7 @@
250 # credentials are saved.250 # credentials are saved.
251 launchpad = NoNetworkLaunchpad.login_with(251 launchpad = NoNetworkLaunchpad.login_with(
252 'app name', launchpadlib_dir=self.temp_dir,252 'app name', launchpadlib_dir=self.temp_dir,
253 service_root='http://api.example.com/beta')253 service_root='http://api.example.com/')
254 credentials_path = os.path.join(254 credentials_path = os.path.join(
255 self.temp_dir, 'api.example.com', 'credentials', 'app name')255 self.temp_dir, 'api.example.com', 'credentials', 'app name')
256 self.assertTrue(os.path.exists(credentials_path))256 self.assertTrue(os.path.exists(credentials_path))
@@ -270,7 +270,7 @@
270 # writable by the user.270 # writable by the user.
271 launchpad = NoNetworkLaunchpad.login_with(271 launchpad = NoNetworkLaunchpad.login_with(
272 'app name', launchpadlib_dir=self.temp_dir,272 'app name', launchpadlib_dir=self.temp_dir,
273 service_root='http://api.example.com/beta')273 service_root='http://api.example.com/')
274 credentials_path = os.path.join(274 credentials_path = os.path.join(
275 self.temp_dir, 'api.example.com', 'credentials', 'app name')275 self.temp_dir, 'api.example.com', 'credentials', 'app name')
276 statinfo = os.stat(credentials_path)276 statinfo = os.stat(credentials_path)
@@ -291,7 +291,7 @@
291291
292 launchpad = NoNetworkLaunchpad.login_with(292 launchpad = NoNetworkLaunchpad.login_with(
293 'app name', launchpadlib_dir=self.temp_dir,293 'app name', launchpadlib_dir=self.temp_dir,
294 service_root='http://api.example.com/beta')294 service_root='http://api.example.com/')
295 self.assertFalse(launchpad.get_token_and_login_called)295 self.assertFalse(launchpad.get_token_and_login_called)
296 self.assertEqual(launchpad.credentials.consumer.key, 'app name')296 self.assertEqual(launchpad.credentials.consumer.key, 'app name')
297 self.assertEqual(297 self.assertEqual(
@@ -335,7 +335,7 @@
335 old_home = os.environ['HOME']335 old_home = os.environ['HOME']
336 os.environ['HOME'] = self.temp_dir336 os.environ['HOME'] = self.temp_dir
337 launchpad = NoNetworkLaunchpad.login_with(337 launchpad = NoNetworkLaunchpad.login_with(
338 'app name', service_root='http://api.example.com/beta')338 'app name', service_root='http://api.example.com/')
339 # Reset the environment to the old value.339 # Reset the environment to the old value.
340 os.environ['HOME'] = old_home340 os.environ['HOME'] = old_home
341341
@@ -375,7 +375,7 @@
375 launchpad = NoNetworkLaunchpad.login_with(375 launchpad = NoNetworkLaunchpad.login_with(
376 'app name', launchpadlib_dir=self.temp_dir,376 'app name', launchpadlib_dir=self.temp_dir,
377 credentials_file=my_credentials_path,377 credentials_file=my_credentials_path,
378 service_root='http://api.example.com/beta')378 service_root='http://api.example.com/')
379 default_credentials_path = os.path.join(379 default_credentials_path = os.path.join(
380 self.temp_dir, 'api.example.com', 'credentials', 'app name')380 self.temp_dir, 'api.example.com', 'credentials', 'app name')
381 self.assertFalse(os.path.exists(default_credentials_path))381 self.assertFalse(os.path.exists(default_credentials_path))
@@ -387,7 +387,7 @@
387 launchpad = NoNetworkLaunchpad.login_with(387 launchpad = NoNetworkLaunchpad.login_with(
388 'app name', launchpadlib_dir=self.temp_dir,388 'app name', launchpadlib_dir=self.temp_dir,
389 credentials_file=my_credentials_path,389 credentials_file=my_credentials_path,
390 service_root='http://api.example.com/beta')390 service_root='http://api.example.com/')
391 self.assertFalse(os.path.exists(default_credentials_path))391 self.assertFalse(os.path.exists(default_credentials_path))
392 self.assertTrue(os.path.exists(my_credentials_path))392 self.assertTrue(os.path.exists(my_credentials_path))
393 self.assertFalse(launchpad.get_token_and_login_called)393 self.assertFalse(launchpad.get_token_and_login_called)

Subscribers

People subscribed via source and target branches