6 new commits in galaxy-central: https://bitbucket.org/galaxy/galaxy-central/commits/10661dcd25e0/ Changeset: 10661dcd25e0 Branch: tmcg/this-pull-request-is-to-extend-the-get-a-1418417648750 User: tmcg Date: 2014-12-12 20:54:33+00:00 Summary: This pull request is to extend the GET /api/jobs request/response. 1.) If the submitted api key is an admin's key AND the request has an included data_range_min or date_range_max value, the response will include jobs from all galaxy users. 2.) The response for (1) is extended to include a) external_id b) galaxy job id c) user email 3.) All users can submit a date range filter. 4.) Existing calls are unaffected by the change: https://galaxy-ng.msi.umn.edu/api/jobs?key=xxxxxxxxxxxxxxxxxxxxxxx [ { "create_time": "2014-12-01T19:23:37.270768", "exit_code": 0, "id": "cffdbbf7257b6526", "model_class": "Job", "state": "ok", "tool_id": "upload1", "update_time": "2014-12-01T19:24:24.608105" } ] 5.) Extended admin request: https://galaxy-ng.msi.umn.edu/api/jobs?date_range_min=2014-11-01?&key=xxxxxADMIN_KEYxxxxxxxxxx ... { "create_time": "2014-11-11T15:59:19.909902", "exit_code": 0, "external_id": "31325.nokomis0015.msi.umn.edu", "id": "2003774561413b68", "job_id": 3733, "model_class": "Job", "state": "ok", "tool_id": "testtoolshed.g2.bx.psu.edu/repos/tmcgowan/fastqc/fastqc/0.60", "update_time": "2014-11-11T16:00:10.872804", "user_email": "mcgo0092+msistaff@msi.umn.edu" }, { "create_time": "2014-11-10T15:15:31.801047", "exit_code": 0, "external_id": "30299.nokomis0015.msi.umn.edu", "id": "e330bc56cb5095a7", "job_id": 3732, "model_class": "Job", "state": "ok", "tool_id": "testtoolshed.g2.bx.psu.edu/repos/jjohnson/snpeff/snpEff/4.0.1", "update_time": "2014-11-10T15:16:31.996148", "user_email": "jj+support@msi.umn.edu" } ... Affected #: 1 file diff -r efd0286e7629289c4c583898ebecb32646834c64 -r 10661dcd25e0888739a83f42b8424c0532efcaa7 lib/galaxy/webapps/galaxy/api/jobs.py --- a/lib/galaxy/webapps/galaxy/api/jobs.py +++ b/lib/galaxy/webapps/galaxy/api/jobs.py @@ -24,9 +24,12 @@ @expose_api def index( self, trans, **kwd ): """ - index( trans, state=None, tool_id=None, history_id=None ) + index( trans, state=None, tool_id=None, history_id=None, date_range_min=None, date_range_max=None ) * GET /api/jobs: return jobs for current user + + !! if user is admin and date_range_[min|max], then + return jobs for all galaxy users based on filtering - this is an extended service :type state: string or list :param state: limit listing of jobs to those that match one of the included states. If none, all are returned. @@ -36,6 +39,12 @@ :type tool_id: string or list :param tool_id: limit listing of jobs to those that match one of the included tool_ids. If none, all are returned. + :type date_range_min: string '2014-01-01' + :param date_range_min: limit the listing of jobs to those updated on or after requested date + + :type date_range_max: string '2014-12-31' + :param date_range_max: limit the listing of jobs to those updated on or before requested date + :type history_id: string :param history_id: limit listing of jobs to those that match the history_id. If none, all are returned. @@ -44,9 +53,15 @@ """ state = kwd.get( 'state', None ) - query = trans.sa_session.query( trans.app.model.Job ).filter( - trans.app.model.Job.user == trans.user - ) + if trans.user_is_admin() and (kwd.get('date_range_min', None) or kwd.get('date_range_max', None)): + is_extended_service = True + else: + is_extended_service = False + + if is_extended_service: + query = trans.sa_session.query( trans.app.model.Job ) + else: + query = trans.sa_session.query( trans.app.model.Job ).filter(trans.app.model.Job.user == trans.user) def build_and_apply_filters( query, objects, filter_func ): if objects is not None: @@ -63,6 +78,9 @@ query = build_and_apply_filters( query, kwd.get( 'tool_id', None ), lambda t: trans.app.model.Job.tool_id == t ) query = build_and_apply_filters( query, kwd.get( 'tool_id_like', None ), lambda t: trans.app.model.Job.tool_id.like(t) ) + + query = build_and_apply_filters( query, kwd.get( 'date_range_min', None ), lambda dmin: trans.app.model.Job.table.c.update_time >= dmin ) + query = build_and_apply_filters( query, kwd.get( 'date_range_max', None ), lambda dmax: trans.app.model.Job.table.c.update_time <= dmax ) history_id = kwd.get( 'history_id', None ) if history_id is not None: @@ -78,7 +96,24 @@ else: order_by = trans.app.model.Job.update_time.desc() for job in query.order_by( order_by ).all(): - out.append( self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) ) + j = self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) + + if is_extended_service: + def get_email_dict(trans): + user_email_dict = {} + user_query = trans.sa_session.query( trans.app.model.User ) + for user in user_query.all(): + u = user.to_dict() + user_email_dict[u['id']] = u['email'] + return user_email_dict + + user_emails = get_email_dict(trans) + j['external_id'] = job.job_runner_external_id + j['job_id'] = job.id + if job.user_id in user_emails: + j['user_email'] = user_emails[job.user_id] + + out.append(j) return out @expose_api https://bitbucket.org/galaxy/galaxy-central/commits/f09646961129/ Changeset: f09646961129 Branch: tmcg/this-pull-request-is-to-extend-the-get-a-1418417648750 User: tmcg Date: 2014-12-15 16:21:36+00:00 Summary: Edits based on reviewer comments. Removed redundant email query, removed job_id from response. Added user_details as param. Affected #: 1 file diff -r 10661dcd25e0888739a83f42b8424c0532efcaa7 -r f09646961129434a993983b81df3d7937b8ed72b lib/galaxy/webapps/galaxy/api/jobs.py --- a/lib/galaxy/webapps/galaxy/api/jobs.py +++ b/lib/galaxy/webapps/galaxy/api/jobs.py @@ -24,11 +24,11 @@ @expose_api def index( self, trans, **kwd ): """ - index( trans, state=None, tool_id=None, history_id=None, date_range_min=None, date_range_max=None ) + index( trans, state=None, tool_id=None, history_id=None, date_range_min=None, date_range_max=None, user_details=False ) * GET /api/jobs: return jobs for current user - !! if user is admin and date_range_[min|max], then + !! if user is admin and user_details is True, then return jobs for all galaxy users based on filtering - this is an extended service :type state: string or list @@ -39,6 +39,9 @@ :type tool_id: string or list :param tool_id: limit listing of jobs to those that match one of the included tool_ids. If none, all are returned. + :type user_details: boolean + :param user_details: if true, and requestor is an admin, will return external job id and user email. + :type date_range_min: string '2014-01-01' :param date_range_min: limit the listing of jobs to those updated on or after requested date @@ -51,9 +54,8 @@ :rtype: list :returns: list of dictionaries containing summary job information """ - state = kwd.get( 'state', None ) - if trans.user_is_admin() and (kwd.get('date_range_min', None) or kwd.get('date_range_max', None)): + if trans.user_is_admin() and kwd.get('user_details', False): is_extended_service = True else: is_extended_service = False @@ -96,24 +98,12 @@ else: order_by = trans.app.model.Job.update_time.desc() for job in query.order_by( order_by ).all(): - j = self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) - + j = self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) if is_extended_service: - def get_email_dict(trans): - user_email_dict = {} - user_query = trans.sa_session.query( trans.app.model.User ) - for user in user_query.all(): - u = user.to_dict() - user_email_dict[u['id']] = u['email'] - return user_email_dict - - user_emails = get_email_dict(trans) j['external_id'] = job.job_runner_external_id - j['job_id'] = job.id - if job.user_id in user_emails: - j['user_email'] = user_emails[job.user_id] - + j['user_email'] = job.user.email out.append(j) + return out @expose_api https://bitbucket.org/galaxy/galaxy-central/commits/f8331619132e/ Changeset: f8331619132e User: jmchilton Date: 2014-12-19 02:49:03+00:00 Summary: Merge pull request #610. Affected #: 1 file diff -r 114b0d3764700a69e87c39f07a1c925a76814261 -r f8331619132ead73b700c337f2fa2610ba92993f lib/galaxy/webapps/galaxy/api/jobs.py --- a/lib/galaxy/webapps/galaxy/api/jobs.py +++ b/lib/galaxy/webapps/galaxy/api/jobs.py @@ -24,9 +24,12 @@ @expose_api def index( self, trans, **kwd ): """ - index( trans, state=None, tool_id=None, history_id=None ) + index( trans, state=None, tool_id=None, history_id=None, date_range_min=None, date_range_max=None, user_details=False ) * GET /api/jobs: return jobs for current user + + !! if user is admin and user_details is True, then + return jobs for all galaxy users based on filtering - this is an extended service :type state: string or list :param state: limit listing of jobs to those that match one of the included states. If none, all are returned. @@ -36,17 +39,31 @@ :type tool_id: string or list :param tool_id: limit listing of jobs to those that match one of the included tool_ids. If none, all are returned. + :type user_details: boolean + :param user_details: if true, and requestor is an admin, will return external job id and user email. + + :type date_range_min: string '2014-01-01' + :param date_range_min: limit the listing of jobs to those updated on or after requested date + + :type date_range_max: string '2014-12-31' + :param date_range_max: limit the listing of jobs to those updated on or before requested date + :type history_id: string :param history_id: limit listing of jobs to those that match the history_id. If none, all are returned. :rtype: list :returns: list of dictionaries containing summary job information """ - state = kwd.get( 'state', None ) - query = trans.sa_session.query( trans.app.model.Job ).filter( - trans.app.model.Job.user == trans.user - ) + if trans.user_is_admin() and kwd.get('user_details', False): + is_extended_service = True + else: + is_extended_service = False + + if is_extended_service: + query = trans.sa_session.query( trans.app.model.Job ) + else: + query = trans.sa_session.query( trans.app.model.Job ).filter(trans.app.model.Job.user == trans.user) def build_and_apply_filters( query, objects, filter_func ): if objects is not None: @@ -63,6 +80,9 @@ query = build_and_apply_filters( query, kwd.get( 'tool_id', None ), lambda t: trans.app.model.Job.tool_id == t ) query = build_and_apply_filters( query, kwd.get( 'tool_id_like', None ), lambda t: trans.app.model.Job.tool_id.like(t) ) + + query = build_and_apply_filters( query, kwd.get( 'date_range_min', None ), lambda dmin: trans.app.model.Job.table.c.update_time >= dmin ) + query = build_and_apply_filters( query, kwd.get( 'date_range_max', None ), lambda dmax: trans.app.model.Job.table.c.update_time <= dmax ) history_id = kwd.get( 'history_id', None ) if history_id is not None: @@ -78,7 +98,12 @@ else: order_by = trans.app.model.Job.update_time.desc() for job in query.order_by( order_by ).all(): - out.append( self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) ) + j = self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) + if is_extended_service: + j['external_id'] = job.job_runner_external_id + j['user_email'] = job.user.email + out.append(j) + return out @expose_api https://bitbucket.org/galaxy/galaxy-central/commits/8d37d708a96c/ Changeset: 8d37d708a96c Branch: tmcg/this-pull-request-is-to-extend-the-get-a-1418417648750 User: jmchilton Date: 2014-12-19 02:49:33+00:00 Summary: Close branch. Affected #: 0 files https://bitbucket.org/galaxy/galaxy-central/commits/943bc5a4c60a/ Changeset: 943bc5a4c60a User: jmchilton Date: 2014-12-19 02:50:15+00:00 Summary: PEP-8 fix for api/jobs.py. Affected #: 1 file diff -r f8331619132ead73b700c337f2fa2610ba92993f -r 943bc5a4c60a0706633660fad41fe9c71fdd8ca3 lib/galaxy/webapps/galaxy/api/jobs.py --- a/lib/galaxy/webapps/galaxy/api/jobs.py +++ b/lib/galaxy/webapps/galaxy/api/jobs.py @@ -27,7 +27,7 @@ index( trans, state=None, tool_id=None, history_id=None, date_range_min=None, date_range_max=None, user_details=False ) * GET /api/jobs: return jobs for current user - + !! if user is admin and user_details is True, then return jobs for all galaxy users based on filtering - this is an extended service @@ -41,10 +41,10 @@ :type user_details: boolean :param user_details: if true, and requestor is an admin, will return external job id and user email. - + :type date_range_min: string '2014-01-01' - :param date_range_min: limit the listing of jobs to those updated on or after requested date - + :param date_range_min: limit the listing of jobs to those updated on or after requested date + :type date_range_max: string '2014-12-31' :param date_range_max: limit the listing of jobs to those updated on or before requested date @@ -59,7 +59,7 @@ is_extended_service = True else: is_extended_service = False - + if is_extended_service: query = trans.sa_session.query( trans.app.model.Job ) else: @@ -80,7 +80,7 @@ query = build_and_apply_filters( query, kwd.get( 'tool_id', None ), lambda t: trans.app.model.Job.tool_id == t ) query = build_and_apply_filters( query, kwd.get( 'tool_id_like', None ), lambda t: trans.app.model.Job.tool_id.like(t) ) - + query = build_and_apply_filters( query, kwd.get( 'date_range_min', None ), lambda dmin: trans.app.model.Job.table.c.update_time >= dmin ) query = build_and_apply_filters( query, kwd.get( 'date_range_max', None ), lambda dmax: trans.app.model.Job.table.c.update_time <= dmax ) @@ -98,12 +98,12 @@ else: order_by = trans.app.model.Job.update_time.desc() for job in query.order_by( order_by ).all(): - j = self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) + j = self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) if is_extended_service: j['external_id'] = job.job_runner_external_id j['user_email'] = job.user.email out.append(j) - + return out @expose_api https://bitbucket.org/galaxy/galaxy-central/commits/815d38c48a56/ Changeset: 815d38c48a56 User: jmchilton Date: 2014-12-19 02:50:15+00:00 Summary: Increase consistency between job index and show. Add tests for new date range and history filtering as well as to ensure only admins get to see external_id and command_line and that users cannot see each other's jobs. Always allow admins to views all jobs on index (instead of only when user_details is specified) and show. Add user_email and external_id to show (for admins) to bring it inline with index and add command_line to index (for admins) to bring it in line with show. Show still allow more details including job standard error and output as well as job metrics. Affected #: 6 files diff -r 943bc5a4c60a0706633660fad41fe9c71fdd8ca3 -r 815d38c48a5639d47eed92806ef4c196406f58f1 lib/galaxy/model/__init__.py --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -520,9 +520,15 @@ dataset.blurb = 'deleted' dataset.peek = 'Job deleted' dataset.info = 'Job output deleted by user before job completed' - def to_dict( self, view='collection' ): + + def to_dict( self, view='collection', system_details=False ): rval = super( Job, self ).to_dict( view=view ) rval['tool_id'] = self.tool_id + if system_details: + # System level details that only admins should have. + rval['external_id'] = self.job_runner_external_id + rval['command_line'] = self.command_line + if view == 'element': param_dict = dict( [ ( p.name, p.value ) for p in self.parameters ] ) rval['params'] = param_dict diff -r 943bc5a4c60a0706633660fad41fe9c71fdd8ca3 -r 815d38c48a5639d47eed92806ef4c196406f58f1 lib/galaxy/web/security/__init__.py --- a/lib/galaxy/web/security/__init__.py +++ b/lib/galaxy/web/security/__init__.py @@ -73,7 +73,7 @@ if not isinstance( rval, dict ): return rval for k, v in rval.items(): - if ( k == 'id' or k.endswith( '_id' ) ) and v is not None and k not in [ 'tool_id' ]: + if ( k == 'id' or k.endswith( '_id' ) ) and v is not None and k not in [ 'tool_id', 'external_id' ]: try: rval[ k ] = self.encode_id( v ) except Exception: diff -r 943bc5a4c60a0706633660fad41fe9c71fdd8ca3 -r 815d38c48a5639d47eed92806ef4c196406f58f1 lib/galaxy/webapps/galaxy/api/jobs.py --- a/lib/galaxy/webapps/galaxy/api/jobs.py +++ b/lib/galaxy/webapps/galaxy/api/jobs.py @@ -55,12 +55,10 @@ :returns: list of dictionaries containing summary job information """ state = kwd.get( 'state', None ) - if trans.user_is_admin() and kwd.get('user_details', False): - is_extended_service = True - else: - is_extended_service = False + is_admin = trans.user_is_admin() + user_details = kwd.get('user_details', False) - if is_extended_service: + if is_admin: query = trans.sa_session.query( trans.app.model.Job ) else: query = trans.sa_session.query( trans.app.model.Job ).filter(trans.app.model.Job.user == trans.user) @@ -98,9 +96,9 @@ else: order_by = trans.app.model.Job.update_time.desc() for job in query.order_by( order_by ).all(): - j = self.encode_all_ids( trans, job.to_dict( 'collection' ), True ) - if is_extended_service: - j['external_id'] = job.job_runner_external_id + job_dict = job.to_dict( 'collection', system_details=is_admin ) + j = self.encode_all_ids( trans, job_dict, True ) + if user_details: j['user_email'] = job.user.email out.append(j) @@ -123,12 +121,13 @@ :returns: dictionary containing full description of job data """ job = self.__get_job( trans, id ) - job_dict = self.encode_all_ids( trans, job.to_dict( 'element' ), True ) + is_admin = trans.user_is_admin() + job_dict = self.encode_all_ids( trans, job.to_dict( 'element', system_details=is_admin ), True ) full_output = util.asbool( kwd.get( 'full', 'false' ) ) if full_output: job_dict.update( dict( stderr=job.stderr, stdout=job.stdout ) ) - if trans.user_is_admin(): - job_dict['command_line'] = job.command_line + if is_admin: + job_dict['user_email'] = job.user.email def metric_to_dict(metric): metric_name = metric.metric_name @@ -199,10 +198,16 @@ decoded_job_id = trans.security.decode_id( id ) except Exception: raise exceptions.MalformedId() - query = trans.sa_session.query( trans.app.model.Job ).filter( - trans.app.model.Job.user == trans.user, - trans.app.model.Job.id == decoded_job_id - ) + query = trans.sa_session.query( trans.app.model.Job ) + if trans.user_is_admin(): + query = query.filter( + trans.app.model.Job.id == decoded_job_id + ) + else: + query = query.filter( + trans.app.model.Job.user == trans.user, + trans.app.model.Job.id == decoded_job_id + ) job = query.first() if job is None: raise exceptions.ObjectNotFound() diff -r 943bc5a4c60a0706633660fad41fe9c71fdd8ca3 -r 815d38c48a5639d47eed92806ef4c196406f58f1 test/api/test_jobs.py --- a/test/api/test_jobs.py +++ b/test/api/test_jobs.py @@ -1,4 +1,6 @@ +import datetime import json +import time from operator import itemgetter from base import api @@ -11,13 +13,18 @@ def test_index( self ): # Create HDA to ensure at least one job exists... self.__history_with_new_dataset() - jobs_response = self._get( "jobs" ) + jobs = self.__jobs_index() + assert "upload1" in map( itemgetter( "tool_id" ), jobs ) - self._assert_status_code_is( jobs_response, 200 ) + def test_system_details_admin_only( self ): + self.__history_with_new_dataset() + jobs = self.__jobs_index( admin=False ) + job = jobs[0] + self._assert_not_has_keys( job, "command_line", "external_id" ) - jobs = jobs_response.json() - assert isinstance( jobs, list ) - assert "upload1" in map( itemgetter( "tool_id" ), jobs ) + jobs = self.__jobs_index( admin=True ) + job = jobs[0] + self._assert_has_keys( job, "command_line", "external_id" ) def test_index_state_filter( self ): # Initial number of ok jobs @@ -31,6 +38,33 @@ new_count = len( self.__uploads_with_state( "ok" ) ) assert original_count < new_count + def test_index_date_filter( self ): + self.__history_with_new_dataset() + two_weeks_ago = (datetime.datetime.utcnow() - datetime.timedelta(7)).isoformat() + last_week = (datetime.datetime.utcnow() - datetime.timedelta(7)).isoformat() + next_week = (datetime.datetime.utcnow() + datetime.timedelta(7)).isoformat() + today = datetime.datetime.utcnow().isoformat() + tomorrow = (datetime.datetime.utcnow() + datetime.timedelta(1)).isoformat() + + jobs = self.__jobs_index( data={"date_range_min": today[0:10], "date_range_max": tomorrow[0:10]} ) + assert len( jobs ) > 0 + today_job_id = jobs[0]["id"] + + jobs = self.__jobs_index( data={"date_range_min": two_weeks_ago, "date_range_max": last_week} ) + assert today_job_id not in map(itemgetter("id"), jobs) + + jobs = self.__jobs_index( data={"date_range_min": last_week, "date_range_max": next_week} ) + assert today_job_id in map(itemgetter("id"), jobs) + + def test_index_history( self ): + history_id, _ = self.__history_with_new_dataset() + jobs = self.__jobs_index( data={"history_id": history_id} ) + assert len( jobs ) > 0 + + history_id = self._new_history() + jobs = self.__jobs_index( data={"history_id": history_id} ) + assert len( jobs ) == 0 + def test_index_multiple_states_filter( self ): # Initial number of ok jobs original_count = len( self.__uploads_with_state( "ok", "new" ) ) @@ -58,6 +92,22 @@ job_details = show_jobs_response.json() self._assert_has_key( job_details, 'id', 'state', 'exit_code', 'update_time', 'create_time' ) + def test_show_security( self ): + history_id, _ = self.__history_with_new_dataset() + jobs_response = self._get( "jobs", data={"history_id": history_id} ) + job = jobs_response.json()[ 0 ] + job_id = job[ "id" ] + + show_jobs_response = self._get( "jobs/%s" % job_id, admin=False ) + self._assert_not_has_keys( show_jobs_response.json(), "command_line", "external_id" ) + + with self._different_user(): + show_jobs_response = self._get( "jobs/%s" % job_id, admin=False ) + self._assert_status_code_is( show_jobs_response, 404 ) + + show_jobs_response = self._get( "jobs/%s" % job_id, admin=True ) + self._assert_has_keys( show_jobs_response.json(), "command_line", "external_id" ) + def test_search( self ): history_id, dataset_id = self.__history_with_ok_dataset() @@ -133,3 +183,10 @@ history_id, dataset_id = self.__history_with_new_dataset() self._wait_for_history( history_id, assert_ok=True ) return history_id, dataset_id + + def __jobs_index( self, **kwds ): + jobs_response = self._get( "jobs", **kwds ) + self._assert_status_code_is( jobs_response, 200 ) + jobs = jobs_response.json() + assert isinstance( jobs, list ) + return jobs diff -r 943bc5a4c60a0706633660fad41fe9c71fdd8ca3 -r 815d38c48a5639d47eed92806ef4c196406f58f1 test/base/api.py --- a/test/base/api.py +++ b/test/base/api.py @@ -11,6 +11,7 @@ from .api_asserts import ( assert_status_code_is, assert_has_keys, + assert_not_has_keys, assert_error_code_is, ) @@ -83,6 +84,9 @@ def _assert_has_keys( self, response, *keys ): assert_has_keys( response, *keys ) + def _assert_not_has_keys( self, response, *keys ): + assert_not_has_keys( response, *keys ) + def _assert_error_code_is( self, response, error_code ): assert_error_code_is( response, error_code ) diff -r 943bc5a4c60a0706633660fad41fe9c71fdd8ca3 -r 815d38c48a5639d47eed92806ef4c196406f58f1 test/base/api_asserts.py --- a/test/base/api_asserts.py +++ b/test/base/api_asserts.py @@ -20,6 +20,11 @@ assert key in response, "Response [%s] does not contain key [%s]" % ( response, key ) +def assert_not_has_keys( response, *keys ): + for key in keys: + assert key not in response, "Response [%s] contains invalid key [%s]" % ( response, key ) + + def assert_error_code_is( response, error_code ): if hasattr( response, "json" ): response = response.json() Repository URL: https://bitbucket.org/galaxy/galaxy-central/ -- This is a commit notification from bitbucket.org. You are receiving this because you have the service enabled, addressing the recipient of this email.