diff --git a/apis/phabricator.py b/apis/phabricator.py index 77cf994..0f08d3a 100644 --- a/apis/phabricator.py +++ b/apis/phabricator.py @@ -24,6 +24,12 @@ class DiffTooLargeError(Exception): pass +class ConduitError(Exception): + def __init__(self, method, result): + super().__init__("Got an error from phabricator calling %s: %s" % (method, result)) + self.error_message = result.get('errorMessage') or "" + + class PhabricatorProvider(BaseProvider, INeedsCommandProvider, INeedsLoggingProvider): def __init__(self, config): if 'url' not in config: @@ -36,6 +42,40 @@ def __init__(self, config): else: self.url = config['url'] + def _call_conduit(self, method, params): + cmd = "echo " + quote_echo_string(json.dumps(params)) + cmd += " | arc call-conduit --conduit-uri=%s %s --" % (self.url, method) + ret = self.run(cmd, shell=True) + try: + result = json.loads(ret.stdout.decode()) + except Exception: + raise Exception("Could not decode response as JSON: %s" % ret.stdout.decode()) + if result['error']: + raise ConduitError(method, result) + return result['response'] + + def _search_one(self, method, constraints, what): + data = self._call_conduit(method, {"constraints": constraints})['data'] + if len(data) != 1: + raise Exception("When querying conduit for %s, we got back %i results" % (what, len(data))) + return data[0] + + def _edit_revision(self, phab_revision, transactions): + return self._call_conduit("differential.revision.edit", {"transactions": transactions, "objectIdentifier": str(phab_revision)}) + + # Only called from is_commandeered, which retries + def _own_phid(self): + if not getattr(self, '_phid', None): + self._phid = self._call_conduit("user.whoami", {})['phid'] + return self._phid + + @logEntryExit + @retry + def is_commandeered(self, phab_revision): + """Whether someone else has made themselves the author of one of our revisions.""" + revision = self._search_one("differential.revision.search", {"ids": [int(phab_revision)]}, "revision %s" % phab_revision) + return revision['fields']['authorPHID'] != self._own_phid() + @logEntryExit def submit_patches(self, bug_id, num_commits): phab_revisions = [] @@ -95,37 +135,8 @@ def submit_to_phabricator(rev_id, retry_attempt=None): # Chain revisions together if needed @retry def chain_revisions(parent_rev, child_rev): - # First get the PHID of the parent revision - cmd = "echo " + quote_echo_string("""{"constraints": {"ids":[%s]}}""" % parent_rev) - cmd += " | arc call-conduit --conduit-uri=%s differential.revision.search --""" % self.url - - ret = self.run(cmd, shell=True) - try: - result = json.loads(ret.stdout.decode()) - except Exception: - raise Exception("Could not decode response as JSON: %s" % ret.stdout.decode()) - - if result['error']: - raise Exception("Got an error from phabricator when trying to search for %s" % (parent_rev)) - - assert 'response' in result - assert 'data' in result['response'] - if len(result['response']['data']) != 1: - raise Exception("When querying conduit for diff %s, we got back %i results" - % (parent_rev, len(result['response']['data']))) - - parent_phid = result['response']['data'][0]['phid'] - - # Now connect them - add parent as parent of child - cmd = "echo " + quote_echo_string("""{"transactions": [{"type":"parents.add", "value":["%s"]}], "objectIdentifier": "%s"}""" % (parent_phid, child_rev)) - cmd += " | arc call-conduit --conduit-uri=%s differential.revision.edit --""" % self.url - ret = self.run(cmd, shell=True) - try: - result = json.loads(ret.stdout.decode()) - except Exception: - raise Exception("Could not decode response as JSON: %s" % ret.stdout.decode()) - if result['error']: - raise Exception("Got an error from phabricator when trying chain revisions, parent: %s, child %s %s" % (parent_rev, child_rev, parent_phid)) + parent_phid = self._search_one("differential.revision.search", {"ids": [int(parent_rev)]}, "revision %s" % parent_rev)['phid'] + self._edit_revision(child_rev, [{"type": "parents.add", "value": [parent_phid]}]) parent_rev = phab_revisions[0] for child_rev in phab_revisions[1:]: @@ -135,15 +146,7 @@ def chain_revisions(parent_rev, child_rev): # Associate the patches with the bug @retry def associate_bug_id(phab_revision): - cmd = "echo " + quote_echo_string("""{"transactions": [{"type":"bugzilla.bug-id", "value":"%s"}], "objectIdentifier": "%s"}""" % (bug_id, phab_revision)) - cmd += " | arc call-conduit --conduit-uri=%s differential.revision.edit --""" % self.url - ret = self.run(cmd, shell=True) - try: - result = json.loads(ret.stdout.decode()) - except Exception: - raise Exception("Could not decode response as JSON: %s" % ret.stdout.decode()) - if result['error']: - raise Exception("Got an error from phabricator when trying to set the bugzilla id for %s" % (phab_revision)) + self._edit_revision(phab_revision, [{"type": "bugzilla.bug-id", "value": str(bug_id)}]) for p in phab_revisions: associate_bug_id(p) @@ -156,59 +159,25 @@ def associate_bug_id(phab_revision): @logEntryExit @retry def set_reviewer(self, phab_revision, phab_username): - # We have to call a different API endpoint if this is a review group + # A review group is a project, with its own search endpoint if phab_username[0] == "#": - # Get the group's phid (groups are implemented as 'projects'') - cmd = "echo " + quote_echo_string("""{"constraints": {"slugs":["%s"]}}""" % phab_username) - cmd += " | arc call-conduit --conduit-uri=%s project.search --""" % self.url + phid = self._search_one("project.search", {"slugs": [phab_username]}, "project %s" % phab_username)['phid'] else: - # Get the user's phid - cmd = "echo " + quote_echo_string("""{"constraints": {"usernames":["%s"]}}""" % phab_username) - cmd += " | arc call-conduit --conduit-uri=%s user.search --""" % self.url - - ret = self.run(cmd, shell=True) - try: - result = json.loads(ret.stdout.decode()) - except Exception: - raise Exception("Could not decode response as JSON: %s" % ret.stdout.decode()) - - if result['error']: - raise Exception("Got an error from phabricator when trying to search for %s" % (phab_username)) - - assert 'response' in result - assert 'data' in result['response'] - if len(result['response']['data']) != 1: - raise Exception("When querying conduit for username %s, we got back %i results" - % (phab_username, len(result['response']['data']))) - - phid = result['response']['data'][0]['phid'] + phid = self._search_one("user.search", {"usernames": [phab_username]}, "username %s" % phab_username)['phid'] # reviewers.add, not reviewers.set: anyone already reviewing should stay a reviewer - cmd = "echo " + quote_echo_string("""{"transactions": [{"type":"reviewers.add", "value":["%s"]}], "objectIdentifier": "%s"}""" % (phid, phab_revision)) - cmd += " | arc call-conduit --conduit-uri=%s differential.revision.edit --""" % self.url - ret = self.run(cmd, shell=True) - try: - result = json.loads(ret.stdout.decode()) - except Exception: - raise Exception("Could not decode response as JSON: %s" % ret.stdout.decode()) - if result['error']: - raise Exception("Got an error from phabricator when trying to add %s (%s) as a reviewer of %s: %s" % (phab_username, phid, phab_revision, result)) + self._edit_revision(phab_revision, [{"type": "reviewers.add", "value": [phid]}]) @logEntryExit @retry def abandon(self, phab_revision): - cmd = "echo " + quote_echo_string("""{"transactions": [{"type":"abandon", "value":true}],"objectIdentifier": "%s"}""" % phab_revision) - cmd += " | arc call-conduit --conduit-uri=%s differential.revision.edit --""" % self.url - ret = self.run(cmd, shell=True) try: - result = json.loads(ret.stdout.decode()) - except Exception: - raise Exception("Could not decode response as JSON: %s" % ret.stdout.decode()) - if result['error']: - if "You can not abandon this revision because it has already been closed." in result['errorMessage']: + self._edit_revision(phab_revision, [{"type": "abandon", "value": True}]) + except ConduitError as e: + if "You can not abandon this revision because it has already been closed." in e.error_message: self.logger.log("Strangely, the phabricator revision %s was already closed when we tried to abandon it. Oh well." % phab_revision, level=LogLevel.Warning) - elif "You can not abandon this revision because you are not the author." in result['errorMessage']: + elif "You can not abandon this revision because you are not the author." in e.error_message: # Someone commandeered the revision, so it's theirs to keep or abandon self.logger.log("The phabricator revision %s has been commandeered by someone else, so we're leaving it alone instead of abandoning it." % phab_revision, level=LogLevel.Info) else: - raise Exception("Got an error from phabricator when trying to abandon %s: %s" % (phab_revision, result)) + raise diff --git a/components/bugzilla.py b/components/bugzilla.py index 9b9144b..ffa186d 100644 --- a/components/bugzilla.py +++ b/components/bugzilla.py @@ -264,6 +264,14 @@ def COMMENT_REJECTED(log_url): def TRY_RUN_SUBMITTED(revision): return "I've submitted a try run for this commit: https://treeherder.mozilla.org/jobs?repo=try&revision=" + revision + @staticmethod + def BUG_SUPERSEDED_BUT_COMMANDEERED(new_bug_id): + return """ +A newer version of this library is in Bug %s. Because someone has taken over the +Phabricator revision here, I'm leaving this bug open instead of closing it as a +duplicate of that one. +""" % new_bug_id + @staticmethod def BUG_SUPERSEDED(): return """ diff --git a/components/db.py b/components/db.py index e3fb2a2..d391ff2 100644 --- a/components/db.py +++ b/components/db.py @@ -10,13 +10,14 @@ from components.logging import LogLevel from components.dbmodels import TryRun, PhabRevision, transform_job_and_try_results_into_objects, JOBSTATUS, JOBOUTCOME, JOBTYPE +import os import pymysql # ================================================================================== -CURRENT_DATABASE_CONFIG_VERSION = 16 +CURRENT_DATABASE_CONFIG_VERSION = 17 CREATION_QUERIES = { "config": """ @@ -58,6 +59,7 @@ `outcome` TINYINT NOT NULL, `relinquished` TINYINT NOT NULL, `bugzilla_id` INT NULL, + `task_id` VARCHAR(32) NULL, PRIMARY KEY (`id`) ) ENGINE = InnoDB; """, @@ -439,6 +441,11 @@ def any_in(needles, haystack): self._query_execute("ALTER TABLE jobs DROP phab_revision") + if config_version <= 16 and CURRENT_DATABASE_CONFIG_VERSION >= 17: + self.logger.log("Upgrading to database version 17", level=LogLevel.Warning) + # The Taskcluster task of the Updatebot run that created the job + self._query_execute("ALTER TABLE `jobs` ADD COLUMN `task_id` VARCHAR(32) NULL AFTER `bugzilla_id`") + query = "UPDATE config SET v=%s WHERE k = 'database_version'" args = (CURRENT_DATABASE_CONFIG_VERSION) self._query_execute(query, args) @@ -573,8 +580,8 @@ def get_job(self, library, new_version): @logEntryExit def create_job(self, jobtype, library, new_version, ff_version, status, outcome, bug_id): # Omitting the created column initializes it to current timestamp - query = "INSERT INTO jobs(job_type, library, version, status, outcome, relinquished, bugzilla_id) VALUES(%s, %s, %s, %s, %s, 0, %s)" - args = (jobtype, library.name, new_version, status, outcome, bug_id) + query = "INSERT INTO jobs(job_type, library, version, status, outcome, relinquished, bugzilla_id, task_id) VALUES(%s, %s, %s, %s, %s, 0, %s, %s)" + args = (jobtype, library.name, new_version, status, outcome, bug_id, os.environ.get('TASK_ID')) job_id = self._query_execute(query, args) query = "INSERT INTO job_to_ff_version(job_id, ff_version) VALUES(%s, %s)" diff --git a/components/dbmodels.py b/components/dbmodels.py index b58cd5c..cd91c93 100644 --- a/components/dbmodels.py +++ b/components/dbmodels.py @@ -120,6 +120,7 @@ def __init__(self, row=None): self.outcome = JOBOUTCOME(row['outcome']) self.relinquished = not not row['relinquished'] self.bugzilla_id = row['bugzilla_id'] + self.task_id = row.get('task_id') self.ff_versions = set() self.phab_revisions = [] self.try_runs = [] diff --git a/tasktypes/vendoring.py b/tasktypes/vendoring.py index 90c9f57..f609c65 100644 --- a/tasktypes/vendoring.py +++ b/tasktypes/vendoring.py @@ -140,6 +140,10 @@ def _process_prior_job(self, prior_job, superseceding_bugzilla_id): if not prior_job.bugzilla_is_open or self.bugzillaProvider.bug_has_landing_link(prior_job.bugzilla_id): self.logger.log("The prior job's bugzilla bug is closed or about to be closed, so we only need to relinquish it.", level=LogLevel.Info) + elif superseceding_bugzilla_id and self._has_commandeered_revision(prior_job): + # Someone is working on the revision themselves, so it isn't ours to close + self.logger.log("The prior job's phabricator revision has been commandeered, so we're leaving its bug open.", level=LogLevel.Info) + self.bugzillaProvider.comment_on_bug(prior_job.bugzilla_id, CommentTemplates.BUG_SUPERSEDED_BUT_COMMANDEERED(superseceding_bugzilla_id)) elif superseceding_bugzilla_id: self.logger.log("The prior job's bugzilla bug is open, marking it as superseded by Bug ID %s ." % superseceding_bugzilla_id, level=LogLevel.Info) @@ -154,6 +158,16 @@ def _process_prior_job(self, prior_job, superseceding_bugzilla_id): self.dbProvider.update_job_relinquish(prior_job) + def _has_commandeered_revision(self, job): + for p in job.phab_revisions: + try: + if self.phabricatorProvider.is_commandeered(p.revision): + return True + except Exception as e: + # If we can't tell, close the bug as we always have + self.logger.log_exception(e) + return False + @logEntryExit def _process_new_job(self, library, task, new_version, timestamp, most_recent_job): if not self._should_process_new_job(library, task, new_version): diff --git a/tests/database.py b/tests/database.py index 882d0d3..414247f 100755 --- a/tests/database.py +++ b/tests/database.py @@ -4,6 +4,7 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. +import os import sys import time import copy @@ -68,8 +69,9 @@ def testJobs(self): try: self.assertEqual(None, self.db.get_job(library, version)) - self.db.create_job(JOBTYPE.VENDORING, library, version, - JOBSTATUS.AWAITING_SECOND_PLATFORMS_TRY_RESULTS, JOBOUTCOME.PENDING, bugid) + with mock.patch.dict(os.environ, {'TASK_ID': 'abc123'}): + self.db.create_job(JOBTYPE.VENDORING, library, version, + JOBSTATUS.AWAITING_SECOND_PLATFORMS_TRY_RESULTS, JOBOUTCOME.PENDING, bugid) newJob = self.db.get_job(library, version) self.assertNotEqual(None, newJob) @@ -79,9 +81,39 @@ def testJobs(self): self.assertEqual(newJob.status, JOBSTATUS.AWAITING_SECOND_PLATFORMS_TRY_RESULTS) self.assertEqual(newJob.outcome, JOBOUTCOME.PENDING) self.assertEqual(newJob.bugzilla_id, bugid) + self.assertEqual(newJob.task_id, 'abc123') finally: self.db.delete_job(job_id=newJob.id) + def testJobWithoutTaskId(self): + # Outside of Taskcluster there's no task to record + library = Struct(**{ + 'name': 'test_library_local', + 'yaml_path': 'path/to/moz.yaml', + }) + version = "test_local_version" + with mock.patch.dict(os.environ): + os.environ.pop('TASK_ID', None) + self.db.create_job(JOBTYPE.VENDORING, library, version, + JOBSTATUS.AWAITING_SECOND_PLATFORMS_TRY_RESULTS, JOBOUTCOME.PENDING, 50) + job = self.db.get_job(library, version) + try: + self.assertIsNone(job.task_id) + finally: + self.db.delete_job(job_id=job.id) + + def testUpgradeAddsTaskId(self): + # Put the database back to version 16, before jobs had a task_id + mysql = self.db.db + mysql._query_execute("ALTER TABLE jobs DROP COLUMN task_id") + mysql._query_execute("UPDATE config SET v=16 WHERE k = 'database_version'") + + self.assertEqual(self.db.check_database(), 17) + columns = [r['COLUMN_NAME'] for r in mysql._query_get_rows( + "SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = %s AND table_name = 'jobs'", + (mysql.database_config['db'],))] + self.assertIn('task_id', columns) + class TestDatabaseConnection(unittest.TestCase): def testRetriesWhileTheServerIsNotUpYet(self): diff --git a/tests/functionality_all_platforms.py b/tests/functionality_all_platforms.py index fc97f47..93e6d49 100755 --- a/tests/functionality_all_platforms.py +++ b/tests/functionality_all_platforms.py @@ -30,10 +30,11 @@ from components.mach_vendor import VendorProvider from components.hg import MercurialProvider from components.scmprovider import SCMProvider +from components.bugzilla import CommentTemplates from apis.taskcluster import TaskclusterProvider from apis.phabricator import PhabricatorProvider -from tests.functionality_utilities import SHARED_COMMAND_MAPPINGS, TRY_OUTPUT, TRY_LOCKED_OUTPUT, TRY_CLOSED_OUTPUT, TRY_TREESTATUS_UNAVAILABLE_OUTPUT, ARC_OUTPUT, CONDUIT_EDIT_OUTPUT, MockedBugzillaProvider, treeherder_response +from tests.functionality_utilities import SHARED_COMMAND_MAPPINGS, TRY_OUTPUT, TRY_LOCKED_OUTPUT, TRY_CLOSED_OUTPUT, TRY_TREESTATUS_UNAVAILABLE_OUTPUT, ARC_OUTPUT, CONDUIT_EDIT_OUTPUT, CONDUIT_USERNAME_SEARCH_OUTPUT, MockedBugzillaProvider, treeherder_response from tests.mock_commandprovider import TestCommandProvider from tests.mock_aiprovider import MockAIProvider from tests.mock_libraryprovider import MockLibraryProvider @@ -1868,6 +1869,76 @@ def abandon_callback(cmd): finally: self._cleanup(u, expected_values) + # Create -> Finish -> someone commandeers the revision -> Create (Issue #384) + # The first job's bug is left open, and its revision isn't abandoned. + @logEntryExitHeaderLine + def testSecondJobFirstRevisionCommandeered(self): + @treeherder_response + def treeherder(request_type, fullpath): + if request_type == TYPE_HEALTH: + return "health_all_success.txt" + else: # TYPE_JOBS + return "jobs_all_success.txt" + + call_counter = [0] + + def git_pretty_output(since_last_job): + lines = [ + "56082fc4acfacba40993e47ef8302993c59e264f|2021-02-09 15:30:04 -0500|2021-02-12 17:40:01 +0000", + "56082fc4acfacba40993e47ef8302993c59e264e|2020-11-12 10:01:18 +0000|2020-11-12 13:10:14 +0000", + "62c10c170bb33f1ad6c9eb13d0cbdf13f95fb27e|2020-11-12 07:00:44 +0000|2020-11-12 08:44:21 +0000", + ] + if call_counter[0] == 0: + return lines[1:] + return lines[0:1] if since_last_job else lines + + def get_filed_bugs(only_open): + if call_counter[0] == 0: + return {} + return OrderedDict({50: {'id': 50, 'assigned_to_detail': {'email': 'nobody@mozilla.org'}}}) + + abandoned = [False] + + def abandon_callback(cmd): + abandoned[0] = True + return CONDUIT_EDIT_OUTPUT + + def conduit_search(): + # Someone other than Updatebot is now the author of the revision + return CONDUIT_USERNAME_SEARCH_OUTPUT.replace("PHID-USER-updatebot0000000000", "PHID-USER-somebodyelse0000000") + + library_filter = 'dav1d' + (u, expected_values, _check_jobs) = self._setup( + library_filter, + git_pretty_output, + lambda: 50 if call_counter[0] == 0 else 51, # get_filed_bug_id_func + get_filed_bugs, + treeherder, + command_callbacks={'abandon': abandon_callback, 'conduit_search': conduit_search} + ) + comments = [] + original_comment_on_bug = u.bugzillaProvider.comment_on_bug + u.bugzillaProvider.comment_on_bug = lambda bug_id, comment, *args, **kwargs: (comments.append((bug_id, comment)), original_comment_on_bug(bug_id, comment, *args, **kwargs)) + dupes = [] + u.bugzillaProvider.dupe_bug = lambda bug_id, comment, dup_id: dupes.append((bug_id, dup_id)) + + try: + u.run(library_filter=library_filter) + u.run(library_filter=library_filter) + _check_jobs(JOBSTATUS.DONE, JOBOUTCOME.ALL_SUCCESS) + + call_counter[0] += 1 + u.run(library_filter=library_filter) + _check_jobs(JOBSTATUS.AWAITING_SECOND_PLATFORMS_TRY_RESULTS, JOBOUTCOME.PENDING) + + all_jobs = u.dbProvider.get_all_jobs() + self.assertTrue(all_jobs[1].relinquished, "The first job should be relinquished.") + self.assertEqual(dupes, [], "Shouldn't close a bug whose revision was commandeered") + self.assertFalse(abandoned[0], "Shouldn't abandon a commandeered revision") + self.assertEqual([c for (b, c) in comments if "taken over the" in c and b == 50], [CommentTemplates.BUG_SUPERSEDED_BUT_COMMANDEERED(51)]) + finally: + self._cleanup(u, expected_values) + # Create -> (Not Done) -> Create @logEntryExitHeaderLine def testSecondJobButFirstIsntDone(self): diff --git a/tests/functionality_utilities.py b/tests/functionality_utilities.py index 179a23b..5dffa47 100644 --- a/tests/functionality_utilities.py +++ b/tests/functionality_utilities.py @@ -28,7 +28,7 @@ Required Callbacks If these commands are run, callbacks must be supplied or it will cause errors. -abandon echo {\"transactions\": [{\"type\":\"abandon\"") +abandon echo {\"transactions\": [{\"type\": \"abandon\"") patch ./mach vendor --patch-mode only @@ -122,11 +122,12 @@ def patch(cmd): ("hg diff --stat", lambda: " accessible/interfaces/ia2/moz.build | 6 +++---\n 1 files changed, 3 insertions(+), 3 deletions(-)\n"), ("arc diff --verbatim", command_callbacks.get('phab_submit', default_phab_submit)), ("arcanist diff --verbatim", command_callbacks.get('phab_submit', default_phab_submit)), - (echo_str("echo {\"constraints\""), lambda: CONDUIT_USERNAME_SEARCH_OUTPUT), # This is also used for differential.revision.search, that's alright - (echo_str("echo {\"transactions\": [{\"type\":\"reviewers.add\""), lambda: CONDUIT_EDIT_OUTPUT), - (echo_str("echo {\"transactions\": [{\"type\":\"abandon\""), command_callbacks.get('abandon', AssertFalse)), - (echo_str("echo {\"transactions\": [{\"type\":\"bugzilla.bug-id\""), lambda: CONDUIT_EDIT_OUTPUT), - (echo_str("echo {\"transactions\": [{\"type\":\"parents.add\""), lambda: CONDUIT_EDIT_OUTPUT), + (echo_str("echo {\"constraints\""), command_callbacks.get('conduit_search', lambda: CONDUIT_USERNAME_SEARCH_OUTPUT)), # This is also used for differential.revision.search, that's alright + (echo_str("echo {}"), lambda: CONDUIT_WHOAMI_OUTPUT), + (echo_str("echo {\"transactions\": [{\"type\": \"reviewers.add\""), lambda: CONDUIT_EDIT_OUTPUT), + (echo_str("echo {\"transactions\": [{\"type\": \"abandon\""), command_callbacks.get('abandon', AssertFalse)), + (echo_str("echo {\"transactions\": [{\"type\": \"bugzilla.bug-id\""), lambda: CONDUIT_EDIT_OUTPUT), + (echo_str("echo {\"transactions\": [{\"type\": \"parents.add\""), lambda: CONDUIT_EDIT_OUTPUT), ("git log -1 --oneline", lambda: "0481f1c (HEAD -> issue-115-add-revision-to-log, origin/issue-115-add-revision-to-log) Issue #115 - Add revision of updatebot to log output"), ("git clone https://example.invalid .", command_callbacks.get('upstream_clone', lambda: "")), ("git merge-base --is-ancestor", lambda cmd: FAKE_HISTORY_IS_ANCESTOR(expected_values, cmd)), @@ -249,7 +250,11 @@ def TRY_OUTPUT(revision, include_auto_line=True): # This is also used for differential.revision.search, that's alright, because it returns a PHID # even if it's not the right type of PHID. CONDUIT_USERNAME_SEARCH_OUTPUT = """ -{"error":null,"errorMessage":null,"response":{"data":[{"id":154,"type":"USER","phid":"PHID-USER-dd6rge2k2csia46r2wcw","fields":{"username":"tjr","realName":"Tom Ritter","roles":["verified","approved","activated"],"dateCreated":1519415695,"dateModified":1519416233,"policy":{"view":"public","edit":"no-one"}},"attachments":[]}],"maps":[],"query":{"queryKey":null},"cursor":{"limit":100,"after":null,"before":null,"order":null}}} +{"error":null,"errorMessage":null,"response":{"data":[{"id":154,"type":"USER","phid":"PHID-USER-dd6rge2k2csia46r2wcw","fields":{"authorPHID":"PHID-USER-updatebot0000000000","username":"tjr","realName":"Tom Ritter","roles":["verified","approved","activated"],"dateCreated":1519415695,"dateModified":1519416233,"policy":{"view":"public","edit":"no-one"}},"attachments":[]}],"maps":[],"query":{"queryKey":null},"cursor":{"limit":100,"after":null,"before":null,"order":null}}} +""" + +CONDUIT_WHOAMI_OUTPUT = """ +{"error":null,"errorMessage":null,"response":{"phid":"PHID-USER-updatebot0000000000","userName":"updatebot","realName":"Updatebot"}} """ CONDUIT_EDIT_OUTPUT = """