diff --git a/contrib/buildbot/.gitignore b/contrib/buildbot/.gitignore new file mode 100644 index 000000000..e8a9f876f --- /dev/null +++ b/contrib/buildbot/.gitignore @@ -0,0 +1,23 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Distribution / packaging +.env +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +*.log +*.log.[0-9]* diff --git a/contrib/buildbot/.template.env b/contrib/buildbot/.template.env new file mode 100644 index 000000000..a18c0fe89 --- /dev/null +++ b/contrib/buildbot/.template.env @@ -0,0 +1,5 @@ +HMAC_BACKPORT_CHECK=bmn6cwzynyo55jol2bazt6yz4gfhc7ry +TEAMCITY_USERNAME=phabricator-staging +TEAMCITY_PASSWORD= +TEAMCITY_CONDUIT_TOKEN= +SLACK_BOT_TOKEN= diff --git a/contrib/buildbot/README.md b/contrib/buildbot/README.md new file mode 100644 index 000000000..df7093716 --- /dev/null +++ b/contrib/buildbot/README.md @@ -0,0 +1,59 @@ +# ABCBot + +This is a microservice designed to sit along side teamcity and take requests from the TCWebHook plugin and act upon them. + +## Install + +Install virtualenvwrapper: +``` +sudo pip3 install virtualenvwrapper +source /usr/local/bin/virtualenvwrapper.sh +``` + +Presuming you have virtualenvwrapper and python 3.6+ installed already: + +```sh +mkvirtualenv abcbot +workon abcbot +pip3 install -r requirements.txt +``` + +## Tests + +Install pytest: +``` +pip3 install pytest +``` + +Run tests: +``` +pytest -v +``` + +## Deployment + +The bot is deployed to a secure environment where the necessary secrets are provided to it. +For example, if using docker: + +Setup the .env file: +``` +cp .template.env .env +vim .env # Edit as needed +``` + +Build: +``` +docker build -t my-docker-tag . +``` + +Use the .env file when running the container: +``` +docker run -it --env-file=.env my-docker-tag +``` + +## Running the server locally + +Running the tests is your best bet for local development. But, if you insist on running a server locally: +``` +./abcbot.py [-l --log-file LOG_FILE] [-p --port PORT] +``` diff --git a/contrib/buildbot/abcbot.py b/contrib/buildbot/abcbot.py new file mode 100755 index 000000000..84786a456 --- /dev/null +++ b/contrib/buildbot/abcbot.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2019 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + + +import sys +import os +import argparse +import logging +import slack + +from logging.handlers import RotatingFileHandler + +from phabricator_wrapper import PhabWrapper +from slackbot import SlackBot +from teamcity import TeamCity +from travis import Travis + +import server + +# Setup global parameters +conduit_token = os.getenv("TEAMCITY_CONDUIT_TOKEN", None) +tc_user = os.getenv("TEAMCITY_USERNAME", None) +tc_pass = os.getenv("TEAMCITY_PASSWORD", None) +phabricatorUrl = os.getenv( + "PHABRICATOR_URL", "https://reviews.bitcoinabc.org/api/") +slack_token = os.getenv('SLACK_BOT_TOKEN', None) + +tc = TeamCity('https://build.bitcoinabc.org', tc_user, tc_pass) +phab = PhabWrapper(host=phabricatorUrl, token=conduit_token) +phab.update_interfaces() +slack_channels = { + # #dev + 'dev': 'C62NSDC6N', + # #abcbot-testing + 'test': 'CQMSVCY66', + # #infra-support + 'infra': 'G016CFAV8KS', +} +slackbot = SlackBot(slack.WebClient, slack_token, slack_channels) +travis = Travis() + + +def main(args): + parser = argparse.ArgumentParser( + description='Continuous integration build bot service.') + parser.add_argument( + '-p', '--port', help='port for server to start', type=int, default=8080) + parser.add_argument( + '-l', '--log-file', help='log file to dump requests payload', type=str, default='log.log') + args = parser.parse_args() + port = args.port + log_file = args.log_file + + app = server.create_server(tc, phab, slackbot, travis) + app.logger.setLevel(logging.INFO) + + formater = logging.Formatter( + '[%(asctime)s] %(levelname)s in %(module)s: %(message)s') + fileHandler = RotatingFileHandler(log_file, maxBytes=10000, backupCount=1) + fileHandler.setFormatter(formater) + app.logger.addHandler(fileHandler) + + app.run(host="0.0.0.0", port=port) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/contrib/buildbot/build.py b/contrib/buildbot/build.py new file mode 100755 index 000000000..fe3e0631f --- /dev/null +++ b/contrib/buildbot/build.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from enum import Enum + + +class BuildStatus(Enum): + Queued = "queued" + Running = "running" + Success = "success" + Failure = "failure" + Unknown = "unknown" + + +class Build: + def __init__(self, build_id, status, name): + self.build_id = build_id + self.status = status + self.name = name + + +class BuildTarget: + def __init__(self, phid): + self.phid = phid + self.builds = {} + + def queue_build(self, build_id, name): + self.builds[build_id] = Build(build_id, BuildStatus.Queued, name) + + def update_build_status(self, build_id, status): + if build_id not in self.builds: + raise AssertionError( + "Attempting to update the build id {} to status {} that does not belong to the build target {}".format( + build_id, status, self.phid + ) + ) + self.builds[build_id].status = status + + def status(self): + # If any build is a failure, the build target is a failure + if any([build.status == BuildStatus.Failure for build in self.builds.values()]): + return BuildStatus.Failure + # If all the builds are a success, the build target is a success + if all([build.status == BuildStatus.Success for build in self.builds.values()]): + return BuildStatus.Success + # If all the builds are queued, the build target is queued + if all([build.status == BuildStatus.Queued for build in self.builds.values()]): + return BuildStatus.Queued + # Otherwise the build target is running + return BuildStatus.Running + + def is_finished(self): + return all([(build.status == BuildStatus.Success or build.status == + BuildStatus.Failure) for build in self.builds.values()]) diff --git a/contrib/buildbot/constants.py b/contrib/buildbot/constants.py new file mode 100755 index 000000000..255a6be3a --- /dev/null +++ b/contrib/buildbot/constants.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + + +from enum import Enum + + +class Deployment(Enum): + DEV = "dev" + STAGING = "staging" + PROD = "prod" diff --git a/contrib/buildbot/phabricator_wrapper.py b/contrib/buildbot/phabricator_wrapper.py new file mode 100755 index 000000000..3d28d1f7f --- /dev/null +++ b/contrib/buildbot/phabricator_wrapper.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + + +from base64 import b64decode +import os + +from build import BuildStatus +from constants import Deployment +from phabricator import Phabricator + + +BUILDNAME_IGNORE_KEYWORD = "__BOTIGNORE" +BITCOIN_ABC_PROJECT_PHID = "PHID-PROJ-z2wrchs62yicqvwlgc5r" +BITCOIN_ABC_REPO = "PHID-REPO-usc6skybawqxzw64opvi" + + +class PhabWrapper(Phabricator): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = None + self.deployment = Deployment( + os.getenv( + "DEPLOYMENT_ENV", + Deployment.DEV)) + self.phid = None + self.file_cache = {} + + def get_current_user_phid(self): + # The current user PHID is not expected to change, so cache the result + if self.phid is None: + self.phid = self.user.whoami()["phid"] + return self.phid + + def getIgnoreKeyword(self): + return BUILDNAME_IGNORE_KEYWORD + + def setLogger(self, logger): + self.logger = logger + + def get_revisionPHID(self, branch): + branch_info = branch.split("/") + # Either refs/tags/* or refs/heads/* + if len(branch_info) < 3: + return False + + if branch_info[-3] != "phabricator" and branch_info[-2] != "diff": + return False + + diffId = int(branch_info[-1]) + diffSearchArgs = { + "constraints": { + "ids": [diffId], + }, + } + data_list = self.differential.diff.search(**diffSearchArgs).data + assert len(data_list) == 1, "differential.diff.search({}): Expected 1 diff, got: {}".format( + diffSearchArgs, data_list) + diffdata = data_list[0] + revisionPHID = diffdata['fields']['revisionPHID'] + return revisionPHID + + def get_revision_info(self, revisionPHID): + revisionSearchArgs = { + "constraints": { + "phids": [revisionPHID], + }, + } + data_list = self.differential.revision.search( + **revisionSearchArgs).data + assert len(data_list) == 1, "differential.revision.search({}): Expected 1 revision, got: {}".format( + revisionSearchArgs, data_list) + diffdata = data_list[0] + revisionId = diffdata['id'] + authorPHID = diffdata['fields']['authorPHID'] + return revisionId, authorPHID + + def getRevisionAuthor(self, revisionId): + # Fetch revision + revisionSearchArgs = { + "constraints": { + "ids": [int(revisionId.strip('D'))], + }, + } + rev_list = self.differential.revision.search(**revisionSearchArgs).data + assert len(rev_list) == 1, "differential.revision.search({}): Expected 1 revision, got: {}".format( + revisionSearchArgs, rev_list) + + # Fetch revision author + userSearchArgs = { + "constraints": { + "phids": [rev_list[0]['fields']['authorPHID']], + }, + } + author_list = self.user.search(**userSearchArgs).data + assert len(author_list) == 1, "user.search({}): Expected 1 user, got: {}".format( + userSearchArgs, author_list) + return author_list[0] + + def getRevisionPHIDsFromCommits(self, commitHashes): + # Fetch commit objects using commit hashes + commitSearchArgs = { + "constraints": { + "repositories": [BITCOIN_ABC_REPO], + "identifiers": commitHashes, + }, + } + commits = self.diffusion.commit.search(**commitSearchArgs).data + expectedNumCommits = len(commitHashes) + assert len(commits) == expectedNumCommits, "diffusion.commit.search({}): Expected {} commits, got: {}".format( + expectedNumCommits, commitSearchArgs, commits) + + # Attempt to get revisions for all commit objects (not all commits have + # revisions) + commitPHIDs = [commit['phid'] for commit in commits] + + edgeSearchArgs = { + "types": ["commit.revision"], + "sourcePHIDs": commitPHIDs, + } + revisionEdges = self.edge.search(**edgeSearchArgs).data + + m = {} + for commit in commits: + commitHash = commit['fields']['identifier'] + m[commitHash] = None + + for edge in (revisionEdges or {}): + if commit['phid'] == edge['sourcePHID']: + m[commitHash] = edge['destinationPHID'] + break + + return m + + def getAuthorSlackUsername(self, author): + # If slack-username is non-empty, use it. Otherwise default to the + # author's Phabricator username + authorSlackUsername = "" + if 'fields' in author: + if 'custom.abc:slack-username' in author['fields']: + authorSlackUsername = author['fields']['custom.abc:slack-username'] + if not authorSlackUsername and 'username' in author['fields']: + authorSlackUsername = author['fields']['username'] + return authorSlackUsername + + def decorateCommitMap(self, commitMapIn): + # For commits that have revisions, get their revision IDs (Dxxxx) + revisionPHIDs = [rev for rev in commitMapIn.values() if rev] + revisionSearchArgs = { + "constraints": { + "phids": revisionPHIDs, + }, + } + revs = self.differential.revision.search(**revisionSearchArgs).data + assert len(revs) == len(revisionPHIDs), "differential.revision.search({}): Expected {} revisions, got: {}".format( + revisionSearchArgs, len(revisionPHIDs), revs) + + # Decorate revision authors + authorPHIDs = [rev['fields']['authorPHID'] for rev in revs] + authors = self.user.search(constraints={ + 'phids': authorPHIDs, + }).data + + # Build map of decorated data + commitMap = {} + for commitHash, revisionPHID in commitMapIn.items(): + decoratedCommit = { + # TODO: Find a better way to get the commit link from + # Phabricator + 'link': "https://reviews.bitcoinabc.org/rABC{}".format(commitHash), + } + if revisionPHID: + for rev in revs: + if revisionPHID == rev['phid']: + decoratedCommit['revision'] = rev + decoratedCommit['link'] = "https://reviews.bitcoinabc.org/D{}".format( + rev['id']) + break + + for author in authors: + if author['phid'] == rev['fields']['authorPHID']: + decoratedCommit['author'] = author + decoratedCommit['authorSlackUsername'] = self.getAuthorSlackUsername( + author) + break + commitMap[commitHash] = decoratedCommit + return commitMap + + def createBuildStatusMessage(self, build_status, buildUrl, buildName): + if not buildUrl: + buildUrl = "#" + + msg = "" + if build_status == BuildStatus.Failure: + msg = "(IMPORTANT) Build [[{} | {}]] failed.".format( + buildUrl, buildName) + elif build_status == BuildStatus.Success: + msg = "Build [[{} | {}]] passed.".format(buildUrl, buildName) + else: + msg = "Build [[{} | {}]] started.".format(buildUrl, buildName) + + return msg + + def commentOnRevision(self, revisionID, msg, buildName=""): + self.logger.info( + "Comment on objectIdentifier '{}': '{}'".format( + revisionID, msg)) + # Production build-bot posts live comments for builds that are not staging-specific + # FIXME: Currently all builds kick off a completion hook in Teamcity. The bot doesn't + # have a better mechanism for knowing if that build is high value (worth commenting on) + # or low value (staging builds, etc.) to end users. Until there is a more streamlined + # way to define Teamcity webhooks to exclude these builds, we are going to look at the + # buildName for an ignore keyword. + if self.deployment == Deployment.PROD and BUILDNAME_IGNORE_KEYWORD not in buildName: + self.differential.revision.edit(transactions=[ + {"type": "comment", "value": msg} + ], objectIdentifier=revisionID) + else: + self.logger.info( + "Comment creation skipped due to deployment environment: '{}'".format( + self.deployment)) + + def getBrokenBuildTaskTitle(self, buildName): + return "Build {} is broken.".format(buildName) + + def getBrokenBuildTask(self, taskTitle): + response = self.maniphest.search(constraints={ + "query": "\"{}\"".format(taskTitle), + "statuses": ["open"], + }) + self.logger.info( + "Response from 'maniphest.search' querying for title '{}': {}".format( + taskTitle, response)) + return response + + def updateBrokenBuildTaskStatus(self, buildName, status): + title = self.getBrokenBuildTaskTitle(buildName) + task_data = self.getBrokenBuildTask(title).data + if len(task_data) == 0: + self.logger.info( + "No existing broken build task with title '{}'. Skipping.".format(title)) + return None + + self.logger.info( + "Updating broken build task T{} status to '{}'.".format( + task_data[0]['id'], status)) + updatedTask = self.maniphest.edit(transactions=[{ + 'type': 'status', + 'value': status, + }], objectIdentifier=task_data[0]['phid']) + self.logger.info( + "Response from 'maniphest.edit' updating status to '{}': {}".format( + status, updatedTask)) + return updatedTask['object'] + + def createBrokenBuildTask( + self, buildName, buildURL, branch, gitCommitsIn, repoCallsign): + gitCommits = [repoCallsign + commit for commit in gitCommitsIn] + title = self.getBrokenBuildTaskTitle(buildName) + res = self.getBrokenBuildTask(title) + if len(res.data) != 0: + self.logger.info( + "Open broken build task (T{}) exists. Skipping creation of a new one.".format( + res.data[0]['id'])) + return None + + task_body = "[[ {} | {} ]] is broken on branch '{}'\n\nAssociated commits:\n{}".format( + buildURL, buildName, branch, "\n".join(gitCommits)) + newTask = self.maniphest.edit(transactions=[ + {"type": "title", "value": title}, + {"type": "priority", "value": "unbreak"}, + {"type": "description", "value": task_body} + ]) + self.logger.info( + "Response from 'maniphest.edit' creating new task with title '{}': {}".format( + title, newTask)) + return newTask['object'] + + def updateRevisionSummary(self, revisionId, summary): + self.logger.info( + "Updated summary on objectIdentifier '{}': '{}'".format( + revisionId, summary)) + if self.deployment == Deployment.PROD: + self.differential.revision.edit(transactions=[{ + "type": "summary", + "value": summary, + }], objectIdentifier=revisionId) + else: + self.logger.info( + "Update of revision summary skipped due to deployment environment: '{}'".format( + self.deployment)) + + def get_project_members(self, project_PHID): + """ Return a list of user PHIDs corresponding to the ABC members """ + project_data = self.project.search( + constraints={ + "phids": [project_PHID], + }, + attachments={ + "members": True, + }, + ).data + + if len(project_data) != 1: + self.logger.info( + "Found {} project(s) while searching for Bitcoin ABC: '{}'".format( + len(project_data), project_data)) + return [] + + return [m["phid"] + for m in project_data[0]["attachments"]["members"]["members"]] + + def get_latest_diff_staging_ref(self, revision_PHID): + diff_data = self.differential.diff.search( + constraints={ + "revisionPHIDs": [revision_PHID], + }, + order="newest" + ).data + + if not diff_data: + self.logger.info( + "Failed to retrieve diff data from revision {}".format(revision_PHID)) + return "" + + # FIXME don't hardcode the staging branch mechanism + return "refs/tags/phabricator/diff/{}".format(diff_data[0]["id"]) + + def get_user_roles(self, user_PHID): + """ Return a list of the user roles for the target user PHID """ + user_data = self.user.search( + constraints={ + "phids": [user_PHID], + } + ).data + + if not user_data: + return [] + + if len(user_data) != 1: + self.logger.info( + "Found {} user(s) while searching for {}: '{}'".format( + len(user_data), + user_PHID, + user_data + ) + ) + return [] + + return user_data[0]["fields"]["roles"] + + def get_latest_master_commit_hash(self): + commit_data = self.diffusion.commit.search( + constraints={ + "repositories": [BITCOIN_ABC_REPO], + }, + limit=1, + ).data + + if not commit_data: + raise AssertionError( + "Failed to get last master commit for repository {}".format(BITCOIN_ABC_REPO)) + + return commit_data[0]["fields"]["identifier"] + + def get_file_content_from_master(self, path): + latest_commit_hash = self.get_latest_master_commit_hash() + + # Level 1 cache: check if the file is cached from the same commit + if path in self.file_cache and self.file_cache[path]['commit'] == latest_commit_hash: + return self.file_cache[path]['content'] + + def file_not_found(data): + raise AssertionError("File {} not found in master commit {} for repository {}:\n{}".format( + path, + latest_commit_hash, + BITCOIN_ABC_REPO, + data, + )) + + # Browse the parent directory to extract the file hash. + # Use a Diffusion browsequery on the parent directory because the + # API will fail if a filename is given. If path is not set the root + # directory is browsed. + browse_data = self.diffusion.browsequery( + path=os.path.dirname(path) or None, + commit=latest_commit_hash, + repository=BITCOIN_ABC_REPO, + branch="master", + ) + + # No file in the directory + if not browse_data or 'paths' not in browse_data: + file_not_found("diffusion.browsequery returned no path data") + + # Loop over the directory content to find our file + file_hash = None + for file in browse_data['paths']: + if file['fullPath'] == path: + file_hash = file['hash'] + + # File not found in it's directory + if not file_hash: + file_not_found(browse_data) + + # Level 2 cache: check if the file did not change since last download + if path in self.file_cache and self.file_cache[path]['hash'] == file_hash: + return self.file_cache[path]['content'] + + # Limit to 5s or 1MB download + file_data = self.diffusion.filecontentquery( + path=path, + commit=latest_commit_hash, + timeout=5, + byteLimit=1024 * 1024, + repository=BITCOIN_ABC_REPO, + branch="master", + ) + + if "filePHID" not in file_data: + file_not_found(file_data) + + if file_data["tooSlow"] or file_data["tooHuge"]: + raise AssertionError("File {} from commit {} for repository {} is oversized or took too long to download: {}".format( + path, + latest_commit_hash, + BITCOIN_ABC_REPO, + file_data, + )) + + file_content = self.file.download( + phid=file_data["filePHID"] + ).response + + if not file_content: + self.logger.info("File {} appear to be empty in commit {} for repository {}".format( + file_data["filePHID"], + latest_commit_hash, + BITCOIN_ABC_REPO, + )) + + self.file_cache.update({ + path: { + 'commit': latest_commit_hash, + 'hash': file_hash, + 'content': b64decode(file_content), + } + }) + + return self.file_cache[path]['content'] + + def set_text_panel_content(self, panel_id, content): + response = self.dashboard.panel.edit( + objectIdentifier=panel_id, + transactions=[ + { + "type": "text", + "value": content + } + ] + ) + + if response.get("error", None): + raise AssertionError( + "Failed to edit panel {} with content:\n{}\n\nPhabricator responded:\n{}\n".format( + panel_id, content, response + ) + ) + + def update_build_target_status(self, build_target, build_id, status): + harbormaster_build_status_mapping = { + BuildStatus.Queued: "work", + BuildStatus.Running: "work", + BuildStatus.Success: "pass", + BuildStatus.Failure: "fail", + } + + build_target.update_build_status(build_id, status) + + self.harbormaster.sendmessage( + buildTargetPHID=build_target.phid, + type=harbormaster_build_status_mapping[build_target.status()] + ) diff --git a/contrib/buildbot/requirements.txt b/contrib/buildbot/requirements.txt new file mode 100644 index 000000000..31250cfb0 --- /dev/null +++ b/contrib/buildbot/requirements.txt @@ -0,0 +1,5 @@ +mock +flask +phabricator==0.7.0 +requests +slackclient diff --git a/contrib/buildbot/resources/teamcity-icon-16.base64 b/contrib/buildbot/resources/teamcity-icon-16.base64 new file mode 100644 index 000000000..d99a0e9d6 --- /dev/null +++ b/contrib/buildbot/resources/teamcity-icon-16.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABhGlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpX5UHOwg4pChCoIFURFHrUIRKoRaoVUHk0s/hCYNSYqLo+BacPBjserg4qyrg6sgCH6AODk6KbpIif9LCi1iPDjux7t7j7t3gFArMc1qGwM03TZTibiYya6IoVeE0IlujAAys4xZSUrCd3zdI8DXuxjP8j/35+hRcxYDAiLxDDNMm3ideGrTNjjvE0dYUVaJz4lHTbog8SPXFY/fOBdcFnhmxEyn5ogjxGKhhZUWZkVTI54kjqqaTvlCxmOV8xZnrVRhjXvyF4Zz+vIS12kOIoEFLEKCCAUVbKAEGzFadVIspGg/7uMfcP0SuRRybYCRYx5laJBdP/gf/O7Wyk+Me0nhOND+4jgfQ0BoF6hXHef72HHqJ0DwGbjSm/5yDZj+JL3a1KJHQO82cHHd1JQ94HIH6H8yZFN2pSBNIZ8H3s/om7JA3y3Qter11tjH6QOQpq6SN8DBITBcoOw1n3d3tPb275lGfz8UP3KBW4PyygAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+QHCQs5GeiTqjUAAAEqSURBVDjLpZI9a8JQFIYfbaFTcFBwsVMo5Ad0KLhmcO+egqsZMmTKlkVwEX+CZHMo+AO6iku3LIFkq1IQ3fNxkziIQRsLkpzpDO99znvecxv93TCnRj0CNN/9So+zz5cTIMuyeg7SNAVgvV6XBKqqYpomg8EAgNlsxmKxAODhDBBCAKBpGr1ej/F4jGVZbDYbDMNAURR0XafdbiNJUqEvAVzXJQxDAIIgwPd9HMfBNE1Wq1XJ3dNfwGUvhCj6PM+vNKUMkiS5CUiShOVyyWg04nA40O12kWWZ6XRa6JuX04QQRaBpmiKEwLZtPM9jPp8zmUzYbrdX7hr93TD/ef2qdMLnb/W0QhRF9f5BHMf1AOcQ9/v9TVGn07kP0Gq1qjn478Z3A95+PyoDjhXqmOx4TU3zAAAAAElFTkSuQmCC \ No newline at end of file diff --git a/contrib/buildbot/server.py b/contrib/buildbot/server.py new file mode 100755 index 000000000..9beb1b70e --- /dev/null +++ b/contrib/buildbot/server.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + + +from build import BuildStatus, BuildTarget +from flask import abort, Flask, request +from functools import wraps +import hashlib +import hmac +import json +import os +from phabricator_wrapper import ( + BITCOIN_ABC_PROJECT_PHID, +) +import re +from shieldio import RasterBadge +from shlex import quote +from teamcity import TeamcityRequestException + + +# Some keywords used by TeamCity and tcWebHook +SUCCESS = "success" +FAILURE = "failure" +RUNNING = "running" +UNRESOLVED = "UNRESOLVED" + +LANDBOT_BUILD_TYPE = "BitcoinAbcLandBot" + + +with open(os.path.join(os.path.dirname(__file__), 'resources', 'teamcity-icon-16.base64'), 'rb') as icon: + BADGE_TC_BASE = RasterBadge( + label='TC build', + logo='data:image/png;base64,{}'.format( + icon.read().strip().decode('utf-8')), + ) + +BADGE_TRAVIS_BASE = RasterBadge( + label='Travis build', + logo='travis' +) + + +def create_server(tc, phab, slackbot, travis, jsonEncoder=None): + # Create Flask app for use as decorator + app = Flask("abcbot") + + # json_encoder can be overridden for testing + if jsonEncoder: + app.json_encoder = jsonEncoder + + phab.setLogger(app.logger) + tc.set_logger(app.logger) + travis.set_logger(app.logger) + + # A collection of the known build targets + create_server.diff_targets = {} + + # Build status panel data + create_server.panel_data = {} + + # Whether the last status check of master was green + create_server.master_is_green = True + + # This decorator specifies an HMAC secret environment variable to use for verifying + # requests for the given route. Currently, we're using Phabricator to trigger these + # routes as webhooks, and a separate HMAC secret is required for each hook. + # Phabricator does not support basic auth for webhooks, so HMAC must be + # used instead. + def verify_hmac(secret_env): + def decorator(fn): + @wraps(fn) + def decorated_function(*args, **kwargs): + secret = os.getenv(secret_env, None) + if not secret: + app.logger.info( + "Error: HMAC env variable '{}' does not exist".format(secret_env)) + abort(401) + + data = request.get_data() + digest = hmac.new( + secret.encode(), data, hashlib.sha256).hexdigest() + + hmac_header = request.headers.get( + 'X-Phabricator-Webhook-Signature') + if not hmac_header: + abort(401) + + if not hmac.compare_digest( + digest.encode(), hmac_header.encode()): + abort(401) + + return fn(*args, **kwargs) + return decorated_function + return decorator + + def get_json_request_data(request): + if not request.is_json: + abort(415, "Expected content-type is 'application/json'") + return request.get_json() + + @app.route("/getCurrentUser", methods=['GET']) + def getCurrentUser(): + return request.authorization.username if request.authorization else None + + @app.route("/backportCheck", methods=['POST']) + @verify_hmac('HMAC_BACKPORT_CHECK') + def backportCheck(): + data = get_json_request_data(request) + revisionId = data['object']['phid'] + + revisionSearchArgs = { + "constraints": { + "phids": [revisionId], + }, + } + data_list = phab.differential.revision.search( + **revisionSearchArgs).data + assert len(data_list) == 1, "differential.revision.search({}): Expected 1 revision, got: {}".format( + revisionSearchArgs, data_list) + summary = data_list[0]['fields']['summary'] + + foundPRs = 0 + multilineCodeBlockDelimiters = 0 + newSummary = "" + for line in summary.splitlines(keepends=True): + multilineCodeBlockDelimiters += len(re.findall(r'```', line)) + + # Only link PRs that do not reside in code blocks + if multilineCodeBlockDelimiters % 2 == 0: + def replacePRWithLink(baseUrl): + def repl(match): + nonlocal foundPRs + # This check matches identation-based code blocks (2+ spaces) + # and common cases for single-line code blocks (using + # both single and triple backticks) + if match.string.startswith(' ') or len( + re.findall(r'`', match.string[:match.start()])) % 2 > 0: + # String remains unchanged + return match.group(0) + else: + # Backport PR is linked inline + foundPRs += 1 + PRNum = match.group(1) + + remaining = '' + if len(match.groups()) >= 2: + remaining = match.group(2) + + return '[[{}/{} | PR{}]]{}'.format( + baseUrl, PRNum, PRNum, remaining) + return repl + + line = re.sub( + r'PR(\d{3}\d+)', + replacePRWithLink( + 'https://github.com/bitcoin/bitcoin/pull'), + line) + + # Be less aggressive about serving libsecp256k1 links. Check + # for some reference to the name first. + if re.search('secp', line, re.IGNORECASE): + line = re.sub(r'PR(\d{2}\d?)([^\d]|$)', replacePRWithLink( + 'https://github.com/bitcoin-core/secp256k1/pull'), line) + + newSummary += line + + if foundPRs > 0: + phab.updateRevisionSummary(revisionId, newSummary) + commentMessage = ("[Bot Message]\n" + "One or more PR numbers were detected in the summary.\n" + "Links to those PRs have been inserted into the summary for reference.") + phab.commentOnRevision(revisionId, commentMessage) + + return SUCCESS, 200 + + @app.route("/build", methods=['POST']) + def build(): + buildTypeId = request.args.get('buildTypeId', None) + ref = request.args.get('ref', 'master') + + PHID = request.args.get('PHID', None) + + abcBuildName = request.args.get('abcBuildName', None) + properties = None + if abcBuildName: + properties = [{ + 'name': 'env.ABC_BUILD_NAME', + 'value': abcBuildName, + }] + + build_id = tc.trigger_build(buildTypeId, ref, PHID, properties)['id'] + if PHID in create_server.diff_targets: + build_target = create_server.diff_targets[PHID] + else: + build_target = BuildTarget(PHID) + build_target.queue_build(build_id, abcBuildName) + create_server.diff_targets[PHID] = build_target + + return SUCCESS, 200 + + @app.route("/buildDiff", methods=['POST']) + def build_diff(): + def get_mandatory_argument(argument): + value = request.args.get(argument, None) + if value is None: + raise AssertionError( + "Calling /buildDiff endpoint with missing mandatory argument {}:\n{}".format( + argument, + request.args + ) + ) + return value + + staging_ref = get_mandatory_argument('stagingRef') + target_phid = get_mandatory_argument('targetPHID') + + # Get the configuration from master + config = json.loads(phab.get_file_content_from_master( + "contrib/teamcity/build-configurations.json")) + + # Get a list of the builds that should run on diffs + builds = [ + k for k, + v in config.get( + 'builds', + {}).items() if v.get( + 'runOnDiff', + False)] + + if target_phid in create_server.diff_targets: + build_target = create_server.diff_targets[target_phid] + else: + build_target = BuildTarget(target_phid) + + for build_name in builds: + properties = [{ + 'name': 'env.ABC_BUILD_NAME', + 'value': build_name, + }] + build_id = tc.trigger_build( + 'BitcoinABC_BitcoinAbcStaging', + staging_ref, + target_phid, + properties)['id'] + build_target.queue_build(build_id, build_name) + + create_server.diff_targets[target_phid] = build_target + return SUCCESS, 200 + + @app.route("/land", methods=['POST']) + def land(): + data = get_json_request_data(request) + + revision = data['revision'] + if not revision: + return FAILURE, 400 + + # conduitToken is expected to be encrypted and will be decrypted by the + # land bot. + conduitToken = data['conduitToken'] + if not conduitToken: + return FAILURE, 400 + + committerName = data['committerName'] + if not committerName: + return FAILURE, 400 + + committerEmail = data['committerEmail'] + if not committerEmail: + return FAILURE, 400 + + properties = [{ + 'name': 'env.ABC_REVISION', + 'value': revision, + }, { + 'name': 'env.ABC_CONDUIT_TOKEN', + 'value': conduitToken, + }, { + 'name': 'env.ABC_COMMITTER_NAME', + 'value': committerName, + }, { + 'name': 'env.ABC_COMMITTER_EMAIL', + 'value': committerEmail, + }] + output = tc.trigger_build( + LANDBOT_BUILD_TYPE, + 'master', + UNRESOLVED, + properties) + if output: + return output + return FAILURE, 500 + + @app.route("/triggerCI", methods=['POST']) + @verify_hmac('HMAC_TRIGGER_CI') + def triggerCI(): + data = get_json_request_data(request) + app.logger.info("Received /triggerCI POST:\n{}".format(data)) + + # We expect a webhook with an edited object and a list of transactions. + if "object" not in data or "transactions" not in data: + return FAILURE, 400 + + data_object = data["object"] + if "type" not in data_object or "phid" not in data_object: + return FAILURE, 400 + + # We are searching for a specially crafted comment to trigger a CI + # build. Only comments on revision should be parsed. Also if there is + # no transaction, or the object is not what we expect, just return. + if data_object["type"] != "DREV" or not data.get('transactions', []): + return SUCCESS, 200 + + revision_PHID = data_object["phid"] + + # Retrieve the transactions details from their PHIDs + transaction_PHIDs = [t["phid"] + for t in data["transactions"] if "phid" in t] + transactions = phab.transaction.search( + objectIdentifier=revision_PHID, + constraints={ + "phids": transaction_PHIDs, + } + ).data + + # Extract the comments from the transaction list. Each transaction + # contains a list of comments. + comments = [c for t in transactions if t["type"] + == "comment" for c in t["comments"]] + # If there is no comment we have no interest in this webhook + if not comments: + return SUCCESS, 200 + + # In order to prevent DoS, only ABC members are allowed to call the bot + # to trigger builds. + # FIXME implement a better anti DoS filter. + abc_members = phab.get_project_members(BITCOIN_ABC_PROJECT_PHID) + comments = [c for c in comments if c["authorPHID"] in abc_members] + + # Check if there is a specially crafted comment that should trigger a + # CI build. Format: + # @bot [build_name ...] + def get_builds_from_comment(comment): + tokens = comment.split() + if not tokens or tokens.pop(0) != "@bot": + return [] + # Escape to prevent shell injection and remove duplicates + return [quote(token) for token in list(set(tokens))] + + builds = [] + for comment in comments: + builds += get_builds_from_comment(comment["content"]["raw"]) + # If there is no build provided, this request is not what we are after, + # just return. + # TODO return an help command to explain how to use the bot. + if not builds: + return SUCCESS, 200 + + staging_ref = phab.get_latest_diff_staging_ref(revision_PHID) + # Trigger the requested builds + for build in builds: + # FIXME the hardcoded infos here should be gathered from somewhere + tc.trigger_build( + "BitcoinABC_BitcoinAbcStaging", + staging_ref, + properties=[{ + 'name': 'env.ABC_BUILD_NAME', + 'value': build, + }] + ) + + # If we reach this point, trigger_build did not raise an exception. + return SUCCESS, 200 + + @app.route("/status", methods=['POST']) + def buildStatus(): + out = get_json_request_data(request) + app.logger.info("Received /status POST with data: {}".format(out)) + return handle_build_result(**out) + + def send_harbormaster_build_link_if_required( + build_link, build_target, build_name): + # Check if a link to the build server has already been sent by searching + # the artifacts. + artifacts = phab.harbormaster.artifact.search( + constraints={ + "buildTargetPHIDs": [build_target.phid], + } + ).data + + build_link_artifact_key = build_name + "-" + build_target.phid + + # Search for the appropriated artifact key in the artifact list. + # If found then the link is already set and there is no need to send it + # again. + for artifact in artifacts: + if "artifactKey" in (artifact["fields"] or { + }) and artifact["fields"]["artifactKey"] == build_link_artifact_key: + return + + phab.harbormaster.createartifact( + buildTargetPHID=build_target.phid, + artifactKey=build_link_artifact_key, + artifactType="uri", + artifactData={ + "uri": build_link, + "name": build_name, + "ui.external": True, + } + ) + + def update_build_status_panel(updated_build_type_id): + # Perform a XOR like operation on the dicts: + # - if a key from target is missing from reference, remove it from + # target. + # - if a key from reference is missing from target, add it to target. + # The default value is the output of the default_value_callback(key). + # - if the key exist in both, don't update it. + # where target is a dictionary updated in place and reference a list of + # keys. + # Returns a tuple of (removed keys, added keys) + def dict_xor(target, reference_keys, default_value_callback): + removed_keys = [ + k for k in list( + target.keys()) if k not in reference_keys] + for key in removed_keys: + del target[key] + + added_keys = [ + k for k in reference_keys if k not in list( + target.keys())] + for key in added_keys: + target[key] = default_value_callback(key) + + return (removed_keys, added_keys) + + panel_content = '' + + def add_line_to_panel(line): + return panel_content + line + '\n' + + def add_project_header_to_panel(project_name): + return panel_content + ( + '| {} | Status |\n' + '|---|---|\n' + ).format(project_name) + + # secp256k1 is a special case because it has a Travis build from a + # Github repo that is not managed by the build-configurations.json config. + # The status always need to be fetched. + sepc256k1_default_branch = 'master' + sepc256k1_travis_status = travis.get_branch_status( + 27431354, sepc256k1_default_branch) + travis_badge_url = BADGE_TRAVIS_BASE.get_badge_url( + message=sepc256k1_travis_status.value, + color='brightgreen' if sepc256k1_travis_status == BuildStatus.Success else 'red', + ) + + # Add secp256k1 Travis to the status panel. + panel_content = add_project_header_to_panel( + 'secp256k1 ([[https://github.com/Bitcoin-ABC/secp256k1 | Github]])') + panel_content = add_line_to_panel( + '| [[{} | {}]] | {{image uri="{}", alt="{}"}} |'.format( + 'https://travis-ci.org/github/bitcoin-abc/secp256k1', + sepc256k1_default_branch, + travis_badge_url, + sepc256k1_travis_status.value, + ) + ) + panel_content = add_line_to_panel('') + + # Download the build configuration from master + config = json.loads(phab.get_file_content_from_master( + "contrib/teamcity/build-configurations.json")) + + # Get a list of the builds to display + config_build_names = [ + k for k, v in config.get( + 'builds', {}).items() if not v.get( + 'hideOnStatusPanel', False)] + + # If there is no build to display, don't update the panel with teamcity + # data + if not config_build_names: + phab.set_text_panel_content(17, panel_content) + return + + # Associate with Teamcity data from the BitcoinABC project + associated_builds = tc.associate_configuration_names( + "BitcoinABC", config_build_names) + + # Get a unique list of the project ids + project_ids = [build["teamcity_project_id"] + for build in list(associated_builds.values())] + project_ids = list(set(project_ids)) + + # Construct a dictionary from teamcity project id to project name. + # This will make it easier to navigate from one to the other. + project_name_map = {} + for build in list(associated_builds.values()): + project_name_map[build['teamcity_project_id'] + ] = build['teamcity_project_name'] + + # If the list of project names has changed (project was added, deleted + # or renamed, update the panel data accordingly. + (removed_projects, added_projects) = dict_xor( + create_server.panel_data, project_ids, lambda key: {}) + + # Log the project changes if any + if (len(removed_projects) + len(added_projects)) > 0: + app.logger.info( + "Teamcity project list has changed.\nRemoved: {}\nAdded: {}".format( + removed_projects, + added_projects, + ) + ) + + # Construct a dictionary from teamcity build type id to build name. + # This will make it easier to navigate from one to the other. + build_name_map = {} + for build in list(associated_builds.values()): + build_name_map[build['teamcity_build_type_id'] + ] = build['teamcity_build_name'] + + def get_build_status_and_message(build_type_id): + latest_build = tc.getLatestCompletedBuild(build_type_id) + # If no build completed, set the status to unknown + if not latest_build: + build_status = BuildStatus.Unknown + build_status_message = build_status.value + else: + + build_info = tc.getBuildInfo(latest_build['id']) + build_status = BuildStatus(build_info['status'].lower()) + build_status_message = build_info.get( + 'statusText', + build_status.value) if build_status == BuildStatus.Failure else build_status.value + + return (build_status, build_status_message) + + # Update the builds + for project_id, project_builds in sorted( + create_server.panel_data.items()): + build_type_ids = [build['teamcity_build_type_id'] for build in list( + associated_builds.values()) if build['teamcity_project_id'] == project_id] + + # If the list of builds has changed (build was added, deleted, + # renamed, added to or removed from the items to display), update + # the panel data accordingly. + (removed_builds, added_builds) = dict_xor( + project_builds, + build_type_ids, + # We need to fetch the satus for each added build + lambda key: get_build_status_and_message(key) + ) + + # Log the build changes if any + if (len(removed_builds) + len(added_builds)) > 0: + app.logger.info( + "Teamcity build list has changed for project {}.\nRemoved: {}\nAdded: {}".format( + project_id, + removed_builds, + added_builds, + ) + ) + + # From here only the build that triggered the call need to be + # updated. Note that it might already be up-to-date if the build was + # part of the added ones. + # Other data remains valid from the previous calls. + if updated_build_type_id not in added_builds and updated_build_type_id in list( + project_builds.keys()): + project_builds[updated_build_type_id] = get_build_status_and_message( + updated_build_type_id) + + # Create a table view of the project: + # + # | | Status | + # |------------------------------------| + # | Link to latest build | Status icon | + # | Link to latest build | Status icon | + # | Link to latest build | Status icon | + panel_content = add_project_header_to_panel( + project_name_map[project_id]) + + for build_type_id, (build_status, + build_status_message) in project_builds.items(): + url = tc.build_url( + "viewLog.html", + { + "buildTypeId": build_type_id, + "buildId": "lastFinished" + } + ) + + # TODO insert Teamcity build failure message + badge_url = BADGE_TC_BASE.get_badge_url( + message=build_status_message, + color=( + 'lightgrey' if build_status == BuildStatus.Unknown + else 'brightgreen' if build_status == BuildStatus.Success + else 'red' + ), + ) + + panel_content = add_line_to_panel( + '| [[{} | {}]] | {{image uri="{}", alt="{}"}} |'.format( + url, + build_name_map[build_type_id], + badge_url, + build_status_message, + ) + ) + panel_content = add_line_to_panel('') + + phab.set_text_panel_content(17, panel_content) + + def update_coverage_panel(coverage_summary): + # FIXME don't harcode the permalink but pull it from some configuration + coverage_permalink = "**[[ https://build.bitcoinabc.org/viewLog.html?buildId=lastSuccessful&buildTypeId=BitcoinABC_Master_BitcoinAbcMasterCoverage&tab=report__Root_Code_Coverage&guest=1 | HTML coverage report ]]**\n\n" + + coverage_report = "| Granularity | % hit | # hit | # total |\n" + coverage_report += "| ----------- | ----- | ----- | ------- |\n" + + # Convert the textual coverage summary report to a pretty remarkup + # content. + # + # The content loooks like this: + # + # Summary coverage rate: + # lines......: 82.3% (91410 of 111040 lines) + # functions..: 74.1% (6686 of 9020 functions) + # branches...: 45.0% (188886 of 420030 branches) + + pattern = r"^\s*(?P\w+)\.+: (?P[0-9.]+%) \((?P\d+) of (?P\d+) .+$" + + for line in coverage_summary.splitlines(): + match = re.match(pattern, line.strip()) + + if not match: + continue + + coverage_report += "| {} | {} | {} | {} |\n".format( + match.group('granularity').capitalize(), + match.group('percent'), + match.group('hit'), + match.group('total'), + ) + + # Update the coverage panel with our remarkup content + phab.set_text_panel_content(21, coverage_permalink + coverage_report) + + def handle_build_result(buildName, buildTypeId, buildResult, + buildURL, branch, buildId, buildTargetPHID, **kwargs): + # Do not report build status for ignored builds + if phab.getIgnoreKeyword() in buildTypeId: + return SUCCESS, 200 + + # Build didn't have a branch + if branch == "UNRESOLVED": + return FAILURE, 400 + + guest_url = tc.convert_to_guest_url(buildURL) + + status = BuildStatus(buildResult) + + isMaster = (branch == "refs/heads/master" or branch == "") + + # If a build completed on master, update the build status panel. + if isMaster and ( + status == BuildStatus.Success or status == BuildStatus.Failure): + update_build_status_panel(buildTypeId) + + # If the build succeeded and there is a coverage report in the build + # artifacts, update the coverage panel. + if status == BuildStatus.Success: + try: + coverage_summary = tc.get_coverage_summary(buildId) + except TeamcityRequestException: + # The coverage report is not guaranteed to exist, in this + # case teamcity will raise an exception. + coverage_summary = None + + if coverage_summary: + update_coverage_panel(coverage_summary) + + # If we have a buildTargetPHID, report the status. + build_target = create_server.diff_targets.get(buildTargetPHID, None) + if build_target is not None: + phab.update_build_target_status(build_target, buildId, status) + + send_harbormaster_build_link_if_required( + guest_url, + build_target, + build_target.builds[buildId].name + ) + + if build_target.is_finished(): + del create_server.diff_targets[buildTargetPHID] + + revisionPHID = phab.get_revisionPHID(branch) + + buildInfo = tc.getBuildInfo(buildId) + isAutomated = tc.checkBuildIsAutomated(buildInfo) + + if isAutomated and status == BuildStatus.Failure: + # Check if this failure is infrastructure-related + buildFailures = tc.getBuildProblems(buildId) + if len(buildFailures) > 0: + # If any infrastructure-related failures occurred, ping the right + # people with a useful message. + buildLog = tc.getBuildLog(buildId) + if re.search(re.escape("[Infrastructure Error]"), buildLog): + slackbot.postMessage('infra', + " There was an infrastructure failure in '{}': {}".format( + buildName, guest_url)) + + # Normally a comment of the build status is provided on diffs. Since no useful debug + # info can be provided that is actionable to the user, we + # give them a short message. + if not isMaster: + phab.commentOnRevision(revisionPHID, + "(IMPORTANT) The build failed due to an unexpected infrastructure outage. " + "The administrators have been notified to investigate. Sorry for the inconvenience.", + buildName) + return SUCCESS, 200 + + # Handle land bot builds + if buildTypeId == LANDBOT_BUILD_TYPE: + if status == BuildStatus.Success or status == BuildStatus.Failure: + properties = buildInfo.getProperties() + revisionId = properties.get( + 'env.ABC_REVISION', 'MISSING REVISION ID') + author = phab.getRevisionAuthor(revisionId) + + landBotMessage = "Failed to land your change:" + if status == BuildStatus.Success: + landBotMessage = "Successfully landed your change:" + + landBotMessage = "{}\nRevision: https://reviews.bitcoinabc.org/{}\nBuild: {}".format( + landBotMessage, revisionId, guest_url) + + # Send a direct message to the revision author + authorSlackUsername = phab.getAuthorSlackUsername(author) + authorSlackUser = slackbot.getUserByName(authorSlackUsername) + + slackChannel = authorSlackUser['id'] if authorSlackUser else None + if not slackChannel: + slackChannel = 'dev' + landBotMessage = "{}: Please set your slack username in your Phabricator profile so the landbot can send you direct messages: {}\n{}".format( + authorSlackUsername, + "https://reviews.bitcoinabc.org/people/editprofile/{}".format( + author['id']), + landBotMessage) + + slackbot.postMessage(slackChannel, landBotMessage) + return SUCCESS, 200 + + # Open/update an associated task and message developers with relevant information if this build was + # the latest completed, automated, master build of its type. + if isMaster and isAutomated: + latestBuild = tc.getLatestCompletedBuild(buildTypeId) + latestBuildId = None + if latestBuild: + latestBuildId = latestBuild.get('id', None) + + logLatestBuildId = 'None' if latestBuildId is None else latestBuildId + app.logger.info( + "Latest completed build ID of type '{}': {}".format( + buildTypeId, logLatestBuildId)) + + if latestBuildId == buildId: + if status == BuildStatus.Success: + updatedTask = phab.updateBrokenBuildTaskStatus( + buildName, 'resolved') + if updatedTask: + # Only message once all of master is green + (buildFailures, testFailures) = tc.getLatestBuildAndTestFailures( + 'BitcoinABC') + if len(buildFailures) == 0 and len(testFailures) == 0: + if not create_server.master_is_green: + create_server.master_is_green = True + slackbot.postMessage( + 'dev', "Master is green again.") + + if status == BuildStatus.Failure: + shortBuildUrl = tc.build_url( + "viewLog.html", + { + "buildId": buildId, + } + ) + + # Get number of build failures over the last few days + numRecentFailures = tc.getNumAggregateFailuresSince( + buildTypeId, 60 * 60 * 24 * 5) + + if numRecentFailures >= 3: + # This build is likely flaky and the channel has + # already been notified. + return SUCCESS, 200 + + if numRecentFailures >= 2: + # This build may be flaky. Ping the channel with a + # less-noisy message. + slackbot.postMessage('dev', + "Build '{}' appears to be flaky: {}".format(buildName, shortBuildUrl)) + return SUCCESS, 200 + + # Only mark master as red for failures that are not flaky + create_server.master_is_green = False + + commitHashes = buildInfo.getCommits() + newTask = phab.createBrokenBuildTask( + buildName, guest_url, branch, commitHashes, 'rABC') + if newTask: + # TODO: Add 'Reviewed by: ' line + + # Do not point to a specific change for scheduled builds, as this generates noise for + # the author of a change that is unlikely to contain + # the root cause of the issue. + if tc.checkBuildIsScheduled(buildInfo): + slackbot.postMessage('dev', + "Scheduled build '{}' appears to be broken: {}\n" + "Task: https://reviews.bitcoinabc.org/T{}".format( + buildName, shortBuildUrl, newTask['id'])) + else: + commitMap = phab.getRevisionPHIDsFromCommits( + commitHashes) + decoratedCommits = phab.decorateCommitMap( + commitMap) + decoratedCommit = decoratedCommits[commitHashes[0]] + changeLink = decoratedCommit['link'] + authorSlackUsername = decoratedCommit['authorSlackUsername'] + authorSlackId = slackbot.formatMentionByName( + authorSlackUsername) + if not authorSlackId: + authorSlackId = authorSlackUsername + + slackbot.postMessage('dev', + "Committer: {}\n" + "Build '{}' appears to be broken: {}\n" + "Task: https://reviews.bitcoinabc.org/T{}\n" + "Diff: {}".format( + authorSlackId, buildName, shortBuildUrl, newTask['id'], changeLink)) + + if not isMaster: + revisionId, authorPHID = phab.get_revision_info(revisionPHID) + + properties = buildInfo.getProperties() + buildConfig = properties.get('env.ABC_BUILD_NAME', None) + if not buildConfig: + buildConfig = properties.get('env.OS_NAME', 'UNKNOWN') + buildName = "{} ({})".format(buildName, buildConfig) + + if status == BuildStatus.Failure: + msg = phab.createBuildStatusMessage( + status, guest_url, buildName) + + # Append a snippet of the log if there are build failures, + # attempting to focus on the first build failure. + buildFailures = tc.getBuildProblems(buildId) + if len(buildFailures) > 0: + buildLog = tc.getBuildLog(buildId) + logLines = [] + for line in buildLog.splitlines(keepends=True): + logLines.append(line) + + # If this line contains any of the build failures, + # append the last N log lines to the message. + foundBuildFailure = None + for failure in buildFailures: + if re.search(re.escape(failure['details']), line): + foundBuildFailure = failure + break + + if foundBuildFailure: + # Recreate the build status message to point to the full build log + # to make the build failure more accessible. + msg = phab.createBuildStatusMessage( + status, foundBuildFailure['logUrl'], buildName) + # We add two newlines to break away from the + # (IMPORTANT) callout. + msg += "\n\nSnippet of first build failure:\n```lines=16,COUNTEREXAMPLE\n{}```".format( + ''.join(logLines[-60:])) + break + + # Append detailed links when there are test failures. + testFailures = tc.getFailedTests(buildId) + if len(testFailures) > 0: + # We add two newlines to break away from the (IMPORTANT) + # callout. + msg += '\n\nEach failure log is accessible here:' + for failure in testFailures: + msg += "\n[[{} | {}]]".format(failure['logUrl'], + failure['name']) + + phab.commentOnRevision(revisionPHID, msg, buildName) + + return SUCCESS, 200 + + return app diff --git a/contrib/buildbot/shieldio.py b/contrib/buildbot/shieldio.py new file mode 100644 index 000000000..a2aaf07d6 --- /dev/null +++ b/contrib/buildbot/shieldio.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from urllib.parse import ( + quote, + unquote, + urlencode, + urlsplit, + urlunsplit +) + + +class Badge: + def __init__(self, **kwargs): + self.base_url = 'https://img.shields.io/static/v1' + + # Provide some defaults, potentially updated by kwargs + self.query = { + 'label': 'shieldio', + 'message': 'unknown', + 'color': 'inactive', + } + self.query.update(kwargs) + + def get_badge_url(self, **kwargs): + scheme, netloc, path = urlsplit(self.base_url)[0:3] + return urlunsplit(( + scheme, + netloc, + path, + unquote(urlencode({**self.query, **kwargs}, doseq=True, quote_via=quote)), + '' + )) + + +class RasterBadge(Badge): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.base_url = 'https://raster.shields.io/static/v1' diff --git a/contrib/buildbot/slackbot.py b/contrib/buildbot/slackbot.py new file mode 100755 index 000000000..6af451570 --- /dev/null +++ b/contrib/buildbot/slackbot.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + + +class SlackBot(): + def __init__(self, clientClass, token, channels): + self.client = clientClass(token=token) + self.channels = channels + + def postMessage(self, channelIn, message): + channel = None + if channelIn and channelIn[0] == 'U': + channel = channelIn + + if channelIn in self.channels: + channel = self.channels[channelIn] + + if not channel: + raise AssertionError( + "Invalid channel: Channel must be a user ID or configured with a channel name") + + self.client.chat_postMessage(channel=channel, text=message) + + def getUsers(self): + # Note: users.list only returns up to 500 users. If this limit is exceeded, + # pagination will need to be implemented. + users = self.client.users_list() + return users['members'] + + def getUserByName(self, username): + # Note: The Slack API does NOT provide a way to search for users and + # recommends the below approach. This is not ideal, but will suffice + # while we have a low user count in ABC slack. + users = self.getUsers() + for user in users: + if username in [user['profile'][nameAttribute] for nameAttribute in [ + 'real_name', + 'real_name_normalized', + 'display_name', + 'display_name_normalized', + ]]: + return user + return None + + def formatMentionByName(self, username): + user = self.getUserByName(username) + if user: + return '<@{}>'.format(user['id']) + return None diff --git a/contrib/buildbot/teamcity.py b/contrib/buildbot/teamcity.py new file mode 100755 index 000000000..070f5f945 --- /dev/null +++ b/contrib/buildbot/teamcity.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python3 + +from collections import UserDict +import io +import json +from pprint import pprint +import re +import requests +import time +from urllib.parse import ( + parse_qs, + urlencode, + urlsplit, + urlunsplit, +) +from zipfile import ZipFile + + +class TeamcityRequestException(Exception): + pass + + +class BuildInfo(UserDict): + @staticmethod + def fromSingleBuildResponse(json_content): + return BuildInfo(json_content['build'][0]) + + def getCommits(self): + return [change['version'] for change in self.data['changes'] + ['change']] if 'changes' in (self.data or {}) else None + + def getProperties(self): + propsList = [] + if 'properties' in (self.data or {}): + propsList = self.data['properties']['property'] + + # Transform list of properties [{'name': name, 'value': value}, ...] into a + # dict {name: value, ...} since we only care about the property values. + properties = {} + for prop in propsList: + properties[prop['name']] = prop['value'] + + return properties if properties else None + + +class TeamCity(): + def __init__(self, base_url, username, password): + self.session = requests.Session() + self.base_url = base_url + self.auth = (username, password) + self.logger = None + self.mockTime = None + + def set_logger(self, logger): + self.logger = logger + + def getTime(self): + if self.mockTime: + return self.mockTime + # time.time() returns a float, so we cast to an int to make it play nice with our other APIs. + # We do not care about sub-second precision anyway. + return int(time.time()) + + def setMockTime(self, mockTime): + self.mockTime = mockTime + + def getResponse(self, request, expectJson=True): + response = self.session.send(request.prepare()) + + if response.status_code != requests.codes.ok: + # Log the entire response, because something went wrong + if self.logger: + self.logger.info( + "Request:\n{}\n\nResponse:\n{}".format( + pprint( + vars(request)), pprint( + vars(response)))) + raise TeamcityRequestException( + "Unexpected Teamcity API error! Status code: {}".format( + response.status_code)) + + content = response.content + if expectJson: + content = json.loads(content) + + # Log the response content to aid in debugging + if self.logger: + self.logger.info(content) + + return content + + def trigger_build(self, buildTypeId, ref, PHID=None, properties=None): + endpoint = self.build_url("app/rest/buildQueue") + + if not properties: + properties = [] + + if PHID is not None: + properties.append({ + 'name': 'env.harborMasterTargetPHID', + 'value': PHID, + }) + + build = { + 'branchName': ref, + 'buildType': { + 'id': buildTypeId + }, + 'properties': { + 'property': properties, + } + } + req = self._request('POST', endpoint, json.dumps(build)) + return self.getResponse(req) + + def get_artifact(self, buildId, path): + endpoint = self.build_url( + "app/rest/builds/id:{}/artifacts/content/{}".format(buildId, path) + ) + + req = self._request('GET', endpoint) + content = self.getResponse(req, expectJson=False) + + if not content: + return None + + return content.decode('utf-8') + + def get_coverage_summary(self, buildId): + return self.get_artifact( + buildId, "coverage.tar.gz!/coverage-summary.txt") + + def get_clean_build_log(self, buildId): + return self.get_artifact(buildId, "artifacts.tar.gz!/build.clean.log") + + def getBuildLog(self, buildId): + # Try to get the clean build log first, then fallback to the full log + try: + clean_log = self.get_clean_build_log(buildId) + if clean_log: + return clean_log + except TeamcityRequestException: + # This is likely a 404 and the log doesn't exist. Either way, + # ignore the failure since there is an alternative log we can + # fetch. + pass + + endpoint = self.build_url( + "downloadBuildLog.html", + { + "buildId": buildId, + "archived": "true", + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req, expectJson=False) + ret = "" + if not content: + ret = "[Error Fetching Build Log]" + else: + z = ZipFile(io.BytesIO(content)) + for filename in z.namelist(): + for line in z.open(filename).readlines(): + ret += line.decode('utf-8') + return ret.replace('\r\n', '\n') + + def getBuildProblems(self, buildId): + endpoint = self.build_url( + "app/rest/problemOccurrences", + { + "locator": "build:(id:{})".format(buildId), + "fields": "problemOccurrence(id,details)", + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req) + if 'problemOccurrence' in (content or {}): + buildFailures = content['problemOccurrence'] + for failure in buildFailures: + # Note: Unlike test failures, build "problems" do not have + # a well-defined focus line in the build log. For now, we + # link to the footer to automatically scroll to the bottom + # of the log where failures tend to be. + failure['logUrl'] = self.build_url( + "viewLog.html", + { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": buildId, + }, + "footer" + ) + return buildFailures + return [] + + def getFailedTests(self, buildId): + endpoint = self.build_url( + "app/rest/testOccurrences", + { + "locator": "build:(id:{}),status:FAILURE".format(buildId), + "fields": "testOccurrence(id,details,name)", + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req) + if 'testOccurrence' in (content or {}): + testFailures = content['testOccurrence'] + for failure in testFailures: + params = { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": buildId, + } + + match = re.search(r'id:(\d+)', failure['id']) + if match: + params['_focus'] = match.group(1) + + failure['logUrl'] = self.build_url( + "viewLog.html", + params + ) + + return testFailures + + return [] + + def getBuildChangeDetails(self, changeId): + endpoint = self.build_url("app/rest/changes/{}".format(changeId)) + req = self._request('GET', endpoint) + return self.getResponse(req) or {} + + def getBuildChanges(self, buildId): + endpoint = self.build_url( + "app/rest/changes", + { + "locator": "build:(id:{})".format(buildId), + "fields": "change(id)" + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req) + if 'change' in (content or {}): + changes = content['change'] + for i, change in enumerate(changes): + changes[i] = self.getBuildChangeDetails(change['id']) + return changes + return [] + + def getBuildInfo(self, buildId): + endpoint = self.build_url( + "app/rest/builds", + { + "locator": "id:{}".format(buildId), + # Note: Wildcard does not match recursively, so if you need data + # from a sub-field, be sure to include it in the list. + "fields": "build(*,changes(*),properties(*),triggered(*))", + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req) + if 'build' in (content or {}): + return BuildInfo.fromSingleBuildResponse(content) + + return BuildInfo() + + def checkBuildIsAutomated(self, buildInfo): + trigger = buildInfo['triggered'] + + # Ignore builds by non-bot users, as these builds may be triggered for + # any reason with various unknown configs + return trigger['type'] != 'user' or trigger['user']['username'] == self.auth[0] + + def checkBuildIsScheduled(self, buildInfo): + trigger = buildInfo['triggered'] + + # Ignore builds by non-bot users, as these builds may be triggered for + # any reason with various unknown configs + return trigger['type'] == 'schedule' + + # For all nested build configurations under a project, fetch the latest + # build failures. + def getLatestBuildAndTestFailures(self, projectId): + buildEndpoint = self.build_url( + "app/rest/problemOccurrences", + { + "locator": "currentlyFailing:true,affectedProject:(id:{})".format(projectId), + "fields": "problemOccurrence(*)", + } + ) + buildReq = self._request('GET', buildEndpoint) + buildContent = self.getResponse(buildReq) + + buildFailures = [] + if 'problemOccurrence' in (buildContent or {}): + buildFailures = buildContent['problemOccurrence'] + + testEndpoint = self.build_url( + "app/rest/testOccurrences", + { + "locator": "currentlyFailing:true,affectedProject:(id:{})".format(projectId), + "fields": "testOccurrence(*)", + } + ) + testReq = self._request('GET', testEndpoint) + testContent = self.getResponse(testReq) + + testFailures = [] + if 'testOccurrence' in (testContent or {}): + testFailures = testContent['testOccurrence'] + + return (buildFailures, testFailures) + + def getLatestCompletedBuild(self, buildType, build_fields=None): + if not build_fields: + build_fields = ['id'] + + endpoint = self.build_url( + "app/rest/builds", + { + "locator": "buildType:{}".format(buildType), + "fields": "build({})".format(",".join(build_fields)), + "count": 1, + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req) + + builds = content.get('build', []) + + # There might be no build completed yet, in this case return None + if not builds: + return None + + # But there should be no more than a single build + if len(builds) > 1: + raise AssertionError( + "Unexpected Teamcity result. Called:\n{}\nGot:\n{}".format( + endpoint, + content + ) + ) + + return builds[0] + + def formatTime(self, seconds): + return time.strftime('%Y%m%dT%H%M%S%z', time.gmtime(seconds)) + + # The returned count is the number of groups of back-to-back failures, not + # the number of individual failures + def getNumAggregateFailuresSince(self, buildType, since): + sinceTime = self.getTime() - since + endpoint = self.build_url( + "app/rest/builds", + { + "locator": "buildType:{},sinceDate:{}".format(buildType, self.formatTime(sinceTime)), + "fields": "build", + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req) + if 'build' in (content or {}): + builds = [{'status': 'SUCCESS'}] + content['build'] + return sum([(builds[i - 1]['status'], builds[i]['status']) + == ('SUCCESS', 'FAILURE') for i in range(1, len(builds))]) + return 0 + + # For each of the given build name from the configuration file, associate the + # teamcity build type id and teamcity build name + def associate_configuration_names(self, project_id, config_names): + # Get all the build configurations related to the given project, and + # heavily filter the output to only return the id, name, project info + # and the property name matching the configuration file. + endpoint = self.build_url( + "app/rest/buildTypes", + { + "locator": "affectedProject:{}".format(project_id), + "fields": "buildType(project(id,name),id,name,parameters($locator(name:env.ABC_BUILD_NAME),property))", + } + ) + req = self._request('GET', endpoint) + content = self.getResponse(req) + + # Example of output: + # "buildType": [ + # { + # "id": "BitcoinABC_Master_Build1", + # "name": "My build 1", + # "project": { + # "id": "BitcoinABC_Master", + # "name": "Master" + # }, + # "parameters": { + # "property": [ + # { + # "name": "env.ABC_BUILD_NAME", + # "value": "build-1" + # } + # ] + # } + # }, + # { + # "id": "BitcoinABC_Master_Build2", + # "name": "My build 2", + # "project": { + # "id": "BitcoinABC_Master", + # "name": "Master" + # }, + # "parameters": { + # "property": [ + # { + # "name": "env.ABC_BUILD_NAME", + # "value": "build-2" + # } + # ] + # } + # } + # ] + + associated_config = {} + for build_type in content.get('buildType', {}): + if 'parameters' not in build_type: + continue + + properties = build_type['parameters'].get('property', []) + for build_property in properties: + # Because of our filter, the only possible property is the one we + # are after. Looking at the value is enough. + config_name = build_property.get('value', None) + if config_name in config_names: + associated_config.update({ + config_name: { + "teamcity_build_type_id": build_type['id'], + "teamcity_build_name": build_type['name'], + "teamcity_project_id": build_type['project']['id'], + "teamcity_project_name": build_type['project']['name'], + } + }) + + return associated_config + + def build_url(self, path="", params=None, fragment=None): + if params is None: + params = {} + + # Make guest access the default when not calling the rest API. + # The caller can explicitly set guest=0 to bypass this behavior. + if "guest" not in params and not path.startswith("app/rest/"): + params["guest"] = 1 + + scheme, netloc = urlsplit(self.base_url)[0:2] + return urlunsplit(( + scheme, + netloc, + path, + urlencode(params, doseq=True), + fragment + )) + + def convert_to_guest_url(self, url): + parsed_url = urlsplit(url) + + # Don't touch unrelated URLs. + parsed_base_url = urlsplit(self.base_url) + if parsed_base_url.scheme != parsed_url.scheme or parsed_base_url.netloc != parsed_url.netloc: + return url + + return self.build_url( + parsed_url.path, + parse_qs(parsed_url.query), + parsed_url.fragment + ) + + def _request(self, verb, url, data=None, headers=None): + if self.logger: + self.logger.info('{}: {}'.format(verb, url)) + + if headers is None: + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + req = requests.Request( + verb, + url, + auth=self.auth, + headers=headers) + req.data = data + + return req diff --git a/contrib/buildbot/test/__init__.py b/contrib/buildbot/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/contrib/buildbot/test/abcbot_fixture.py b/contrib/buildbot/test/abcbot_fixture.py new file mode 100644 index 000000000..7f3158436 --- /dev/null +++ b/contrib/buildbot/test/abcbot_fixture.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import base64 +import json +import hashlib +import hmac +import os +from pathlib import Path +import server +import unittest + +import test.mocks.fixture +import test.mocks.phabricator +import test.mocks.slackbot +import test.mocks.teamcity + +# Setup global parameters +TEST_USER = "TESTUSER" +TEST_PASSWORD = "TESTPASSWORD" + + +class ABCBotFixture(unittest.TestCase): + def __init__(self, methodName='runTest'): + super().__init__(methodName) + + self.hmac_secret = "bmn6cwzynyo55jol2bazt6yz4gfhc7ry" + os.environ["HMAC_BACKPORT_CHECK"] = self.hmac_secret + os.environ["HMAC_TRIGGER_CI"] = self.hmac_secret + os.environ["WEBHOOK_PASSWORD"] = TEST_PASSWORD + os.environ["DEPLOYMENT_ENV"] = "prod" + + self.data_dir = Path(__file__).parent / "data" + self.credentials = base64.b64encode("{}:{}".format( + TEST_USER, TEST_PASSWORD).encode()).decode('utf-8') + self.headers = {'Authorization': 'Basic ' + self.credentials} + + def setUp(self): + self.phab = test.mocks.phabricator.instance() + self.slackbot = test.mocks.slackbot.instance() + self.teamcity = test.mocks.teamcity.instance() + self.travis = test.mocks.travis.instance() + self.app = server.create_server( + self.teamcity, + self.phab, + self.slackbot, + self.travis, + test.mocks.fixture.MockJSONEncoder).test_client() + + def tearDown(self): + pass + + def compute_hmac(self, data): + return hmac.new(self.hmac_secret.encode(), + data.encode(), hashlib.sha256).hexdigest() + + def post_data_with_hmac(self, path, headers, data): + headers['X-Phabricator-Webhook-Signature'] = self.compute_hmac(data) + response = self.app.post(path, headers=headers, data=data) + return response + + def post_json_with_hmac(self, path, headers, obj): + data = json.dumps(obj) + headers['X-Phabricator-Webhook-Signature'] = self.compute_hmac(data) + response = self.app.post(path, headers=headers, json=obj) + return response diff --git a/contrib/buildbot/test/data/testlog.output.txt b/contrib/buildbot/test/data/testlog.output.txt new file mode 100644 index 000000000..445fb6698 --- /dev/null +++ b/contrib/buildbot/test/data/testlog.output.txt @@ -0,0 +1,60 @@ +[22:23:09] : [Step 1/1] Running tests: radix_tests from ../../src/test/radix_tests.cpp +[22:23:10] : [Step 1/1] Running tests: raii_event_tests from ../../src/test/raii_event_tests.cpp +[22:23:10] : [Step 1/1] Running tests: random_tests from ../../src/test/random_tests.cpp +[22:23:11] : [Step 1/1] Running tests: rcu_tests from ../../src/test/rcu_tests.cpp +[22:23:11] : [Step 1/1] Running tests: reverselock_tests from ../../src/test/reverselock_tests.cpp +[22:23:12] : [Step 1/1] Running tests: rpc_tests from ../../src/test/rpc_tests.cpp +[22:23:12] : [Step 1/1] Running tests: rpc_server_tests from ../../src/test/rpc_server_tests.cpp +[22:23:13] : [Step 1/1] Running tests: sanity_tests from ../../src/test/sanity_tests.cpp +[22:23:13] : [Step 1/1] Running tests: rwcollection_tests from ../../src/test/rwcollection_tests.cpp +[22:23:13] : [Step 1/1] Running tests: scheduler_tests from ../../src/test/scheduler_tests.cpp +[22:23:13] : [Step 1/1] Running tests: schnorr_tests from ../../src/test/schnorr_tests.cpp +[22:23:13] : [Step 1/1] Running tests: script_commitment_tests from ../../src/test/script_commitment_tests.cpp +[22:23:13] : [Step 1/1] Running tests: script_bitfield_tests from ../../src/test/script_bitfield_tests.cpp +[22:23:14] : [Step 1/1] Running tests: script_p2sh_tests from ../../src/test/script_p2sh_tests.cpp +[22:23:14] : [Step 1/1] Running tests: script_standard_tests from ../../src/test/script_standard_tests.cpp +[22:23:14] : [Step 1/1] Running tests: script_tests from ../../src/test/script_tests.cpp +[22:23:14] : [Step 1/1] Running tests: scriptnum_tests from ../../src/test/scriptnum_tests.cpp +[22:23:15] : [Step 1/1] Running tests: serialize_tests from ../../src/test/serialize_tests.cpp +[22:23:16] : [Step 1/1] Running tests: sigcache_tests from ../../src/test/sigcache_tests.cpp +[22:23:17] : [Step 1/1] Running tests: sigencoding_tests from ../../src/test/sigencoding_tests.cpp +[22:23:17] : [Step 1/1] Running tests: sighash_tests from ../../src/test/sighash_tests.cpp +[22:23:18] : [Step 1/1] Running tests: sighashtype_tests from ../../src/test/sighashtype_tests.cpp +[22:23:19] : [Step 1/1] Running tests: sigopcount_tests from ../../src/test/sigopcount_tests.cpp +[22:23:19] : [Step 1/1] Running tests: skiplist_tests from ../../src/test/skiplist_tests.cpp +[22:23:20] : [Step 1/1] Running tests: streams_tests from ../../src/test/streams_tests.cpp +[22:23:21] : [Step 1/1] Running tests: sync_tests from ../../src/test/sync_tests.cpp +[22:23:21] : [Step 1/1] Running tests: timedata_tests from ../../src/test/timedata_tests.cpp +[22:23:21] : [Step 1/1] Running tests: torcontrol_tests from ../../src/test/torcontrol_tests.cpp +[22:23:21] : [Step 1/1] Running tests: transaction_tests from ../../src/test/transaction_tests.cpp +[22:23:25] : [Step 1/1] Running tests: txindex_tests from ../../src/test/txindex_tests.cpp +[22:23:26] : [Step 1/1] Running tests: txvalidation_tests from ../../src/test/txvalidation_tests.cpp +[22:23:26] : [Step 1/1] Running tests: txvalidationcache_tests from ../../src/test/txvalidationcache_tests.cpp +[22:23:26] : [Step 1/1] Running tests: uint256_tests from ../../src/test/uint256_tests.cpp +[22:23:27] : [Step 1/1] Running tests: undo_tests from ../../src/test/undo_tests.cpp +[22:23:27] : [Step 1/1] Running tests: util_tests from ../../src/test/util_tests.cpp +[22:23:31] : [Step 1/1] Running tests: validation_block_tests from ../../src/test/validation_block_tests.cpp +[22:23:33]W: [Step 1/1] /bin/bash: line 1: 30761 Aborted test/test_bitcoin -l test_suite -t "`cat ../../src/test/validation_block_tests.cpp | grep "BOOST_FIXTURE_TEST_SUITE(\|BOOST_AUTO_TEST_SUITE(" | cut -d '(' -f 2 | cut -d ',' -f 1 | cut -d ')' -f 1`" > test/validation_block_tests.cpp.test.log 2>&1 +[22:23:33] : [Step 1/1] Running 1 test case... +[22:23:33] : [Step 1/1] [1;34;49mEntering test module "Bitcoin Test Suite" +[22:23:33] : [Step 1/1] [0;39;49m [1;34;49m../../src/test/validation_block_tests.cpp(24): Entering test suite "validation_block_tests" +[22:23:33] : [Step 1/1] [0;39;49m [1;34;49m../../src/test/validation_block_tests.cpp(133): Entering test case "processnewblock_signals_ordering" +[22:23:33]W: [Step 1/1] make[3]: *** [Makefile:13500: test/validation_block_tests.cpp.test] Error 1 +[22:23:33] : [Step 1/1] test_bitcoin: ../../src/validation.cpp:5452: void CChainState::CheckBlockIndex(const Consensus::Params&): Assertion `pindex->nStatus.isOnParkedChain() || setBlockIndexCandidates.count(pindex)' failed. +[22:23:33]W: [Step 1/1] make[3]: *** Waiting for unfinished jobs.... +[22:23:33] : [Step 1/1] [0;39;49munknown location(0): [4;31;49mfatal error: in "validation_block_tests/processnewblock_signals_ordering": signal: SIGABRT (application abort requested) [0;39;49m +[22:23:33] : [Step 1/1] ../../src/test/validation_block_tests.cpp(46): [1;36;49mlast checkpoint [0;39;49m +[22:23:33] : [Step 1/1] [1;34;49m../../src/test/validation_block_tests.cpp(133): Leaving test case "processnewblock_signals_ordering"; testing time: 57632us +[22:23:33] : [Step 1/1] [0;39;49m [1;34;49m../../src/test/validation_block_tests.cpp(24): Leaving test suite "validation_block_tests"; testing time: 57652us +[22:23:33] : [Step 1/1] [0;39;49m [1;34;49mLeaving test module "Bitcoin Test Suite"; testing time: 57804us +[22:23:33] : [Step 1/1] [0;39;49m +[22:23:33] : [Step 1/1] [1;31;49m*** 1 failure is detected in the test module "Bitcoin Test Suite" +[22:23:33] : [Step 1/1] [0;39;49mtest_bitcoin: /usr/include/boost/thread/pthread/condition_variable_fwd.hpp:85: boost::condition_variable::~condition_variable(): Assertion `!ret' failed. +[22:23:39] : [Step 1/1] make[3]: Leaving directory '/home/teamcity/buildAgent/work/78fe5e47cf5a299e/build/src' +[22:23:39]W: [Step 1/1] make[2]: *** [Makefile:12302: check-am] Error 2 +[22:23:39] : [Step 1/1] make[2]: Leaving directory '/home/teamcity/buildAgent/work/78fe5e47cf5a299e/build/src' +[22:23:39] : [Step 1/1] make[1]: Leaving directory '/home/teamcity/buildAgent/work/78fe5e47cf5a299e/build/src' +[22:23:39]W: [Step 1/1] make[1]: *** [Makefile:11987: check-recursive] Error 1 +[22:23:39]W: [Step 1/1] make: *** [Makefile:765: check-recursive] Error 1 +[22:23:39]W: [Step 1/1] Process exited with code 2 +[22:23:39]E: [Step 1/1] Process exited with code 2 (Step: Command Line) diff --git a/contrib/buildbot/test/data/testlog.zip b/contrib/buildbot/test/data/testlog.zip new file mode 100644 index 000000000..674165180 Binary files /dev/null and b/contrib/buildbot/test/data/testlog.zip differ diff --git a/contrib/buildbot/test/data/testlog_infrafailure.zip b/contrib/buildbot/test/data/testlog_infrafailure.zip new file mode 100644 index 000000000..ac6204eaa Binary files /dev/null and b/contrib/buildbot/test/data/testlog_infrafailure.zip differ diff --git a/contrib/buildbot/test/mocks/__init__.py b/contrib/buildbot/test/mocks/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/contrib/buildbot/test/mocks/fixture.py b/contrib/buildbot/test/mocks/fixture.py new file mode 100755 index 000000000..1971ded55 --- /dev/null +++ b/contrib/buildbot/test/mocks/fixture.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from flask.json import JSONEncoder + +# Dummy values to be specified in tests + + +class MockData: + pass + +# TODO: When Python3.7 becomes the minimum version, remove MockJSONEncoder and +# MockData base class. Decorate data classes with @dataclass from package +# 'dataclasses' instead. + + +class MockJSONEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, MockData): + return o.__dict__ + return super(self).default(o) diff --git a/contrib/buildbot/test/mocks/phabricator.py b/contrib/buildbot/test/mocks/phabricator.py new file mode 100755 index 000000000..40d696a32 --- /dev/null +++ b/contrib/buildbot/test/mocks/phabricator.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import mock + +from phabricator_wrapper import PhabWrapper + + +class Result: + def __init__(self, data=[]): + self.data = data + + +def instance(): + phab = None + phab = PhabWrapper(host="https://phabricator.test") + + phab.logger = mock.Mock() + + phab.dashboard = mock.Mock() + phab.dashboard.panel = mock.Mock() + + phab.differential = mock.Mock() + phab.differential.diff = mock.Mock() + phab.differential.diff.search.return_value = Result([]) + phab.differential.revision.return_value = Result([]) + phab.differential.revision.search.return_value = Result([]) + + phab.diffusion = mock.Mock() + phab.diffusion.commit = mock.Mock() + phab.diffusion.commit.search.return_value = Result([]) + + phab.edge = mock.Mock() + + phab.file = mock.Mock() + + phab.harbormaster = mock.Mock() + phab.harbormaster.artifact.search.return_value = Result([]) + + phab.maniphest = mock.Mock() + phab.maniphest.search.return_value = Result([]) + + phab.project = mock.Mock() + phab.project.search.return_value = Result([]) + + phab.transaction = mock.Mock() + phab.transaction.search.return_value = Result([]) + + phab.user = mock.Mock() + phab.user.search.return_value = Result([]) + + return phab + + +DEFAULT_REVISION_ID = 1000 +DEFAULT_USER_ID = 100 + + +def differential_revision_search_result(total=1): + results = [] + for i in range(total): + revisionId = DEFAULT_REVISION_ID + i + results.append({ + 'id': revisionId, + 'phid': 'PHID-DREV-{}'.format(revisionId), + 'fields': { + 'authorPHID': 'PHID-USER-{}'.format(DEFAULT_USER_ID + i), + } + }) + return Result(results) diff --git a/contrib/buildbot/test/mocks/slackbot.py b/contrib/buildbot/test/mocks/slackbot.py new file mode 100755 index 000000000..014a01168 --- /dev/null +++ b/contrib/buildbot/test/mocks/slackbot.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import mock + +from slackbot import SlackBot + + +def instance(): + channels = { + 'dev': '#test-dev-channel', + 'infra': '#infra-support-channel', + } + slackbot = SlackBot(mock.Mock, 'slack-token', channels) + return slackbot + + +DEFAULT_USER_NUM = 1000 +DEFAULT_USER_ID = 'U{}'.format(DEFAULT_USER_NUM) + + +def userProfile(attributes=None): + profile = { + 'real_name': 'Real Name', + 'real_name_normalized': 'Real Name Normalized', + 'display_name': 'Display Name', + 'display_name_normalized': 'Display Name Normalized', + } + if attributes: + profile = {**profile, **attributes} + return profile + + +def user(userId=DEFAULT_USER_ID, profile=None): + # Slack userIds always begin with a 'U' character + assert userId[0] == 'U' + + if profile is None: + profile = userProfile() + + return { + 'id': userId, + 'profile': profile, + } + + +def users_list(total=1, initialUsers=[]): + users = [] + users.extend(initialUsers) + for i in range(len(users), total): + users.append(user('U{}'.format(DEFAULT_USER_NUM + i))) + return { + 'members': users, + } diff --git a/contrib/buildbot/test/mocks/teamcity.py b/contrib/buildbot/test/mocks/teamcity.py new file mode 100755 index 000000000..ac22d744b --- /dev/null +++ b/contrib/buildbot/test/mocks/teamcity.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import json +import mock +import requests + +from teamcity import TeamCity + + +TEAMCITY_BASE_URL = 'https://teamcity.test' +TEAMCITY_CI_USER = 'teamcity-ci-user' + +DEFAULT_BUILD_ID = 123456 + + +def instance(): + teamcity = TeamCity( + TEAMCITY_BASE_URL, + TEAMCITY_CI_USER, + "teamcity-users-password") + teamcity.session = mock.Mock() + teamcity.session.send.return_value = mock.Mock() + teamcity.session.send.return_value.status_code = requests.codes.ok + return teamcity + + +class Response: + def __init__(self, content=json.dumps({}), status_code=requests.codes.ok): + self.content = content + self.status_code = status_code + + +def buildInfo_changes(commits=None): + changes = [] + for commit in commits or []: + changes.append({'version': commit}) + + return { + 'count': len(changes), + 'change': changes, + } + + +def buildInfo_properties(propsList=None): + if not propsList: + propsList = [] + + return { + 'count': len(propsList), + 'property': propsList, + } + + +def buildInfo_triggered(triggerType='vcs', username='test-username'): + triggered = { + 'type': triggerType, + } + + if triggerType == 'user': + triggered['user'] = { + 'username': username, + } + + return triggered + + +def buildInfo(changes=None, properties=None, triggered=None, + build_id=None, buildqueue=False): + if not changes: + changes = buildInfo_changes( + ['deadbeef00000111222333444555666777888000']) + + if not triggered: + triggered = buildInfo_triggered() + + if not properties: + properties = buildInfo_properties() + + if build_id is None: + build_id = DEFAULT_BUILD_ID + + # If we are mocking the build endpoint, we should add a root 'build' + # element, but if we are mocking the buildqueue endpoint, it should not be + # there. + output = { + 'id': build_id, + 'changes': changes, + 'triggered': triggered, + 'properties': properties, + } + + if not buildqueue: + output = {'build': [output]} + + return Response(json.dumps(output)) + + +def buildInfo_automatedBuild(): + return buildInfo(triggered=buildInfo_triggered( + triggerType='user', username=TEAMCITY_CI_USER)) + + +def buildInfo_userBuild(username='test-username'): + return buildInfo(triggered=buildInfo_triggered( + triggerType='user', username=username)) + + +def buildInfo_scheduledBuild(): + return buildInfo(triggered=buildInfo_triggered(triggerType='schedule')) + + +def buildInfo_vcsCheckinBuild(): + return buildInfo(triggered=buildInfo_triggered(triggerType='vcs')) diff --git a/contrib/buildbot/test/mocks/travis.py b/contrib/buildbot/test/mocks/travis.py new file mode 100755 index 000000000..2960146b2 --- /dev/null +++ b/contrib/buildbot/test/mocks/travis.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import mock +import requests +from travis import Travis + + +def instance(): + travis = Travis(base_url="https://test.travis-ci.org") + travis.session = mock.Mock() + travis.session.send = mock.Mock() + travis.session.send.return_value.status_code = requests.codes.ok + + return travis diff --git a/contrib/buildbot/test/test_build.py b/contrib/buildbot/test/test_build.py new file mode 100755 index 000000000..f126d5afc --- /dev/null +++ b/contrib/buildbot/test/test_build.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + + +from build import BuildStatus, BuildTarget +import unittest + + +class BuildTests(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_buildTarget(self): + build_target_phid = "PHID-HMBT-123456" + + # Create a build target + build_target = BuildTarget(build_target_phid) + + # Queue a few builds + for i in range(10): + name = "build-{}".format(i) + build_target.queue_build(i, name) + self.assertEqual(len(build_target.builds), i + 1) + self.assertEqual(build_target.builds[i].status, BuildStatus.Queued) + self.assertEqual(build_target.status(), BuildStatus.Queued) + self.assertEqual(build_target.is_finished(), False) + + # Update the status of a single build to running, the build target + # should be running + build_target.update_build_status(3, BuildStatus.Running) + self.assertEqual(build_target.builds[3].status, BuildStatus.Running) + self.assertEqual(build_target.status(), BuildStatus.Running) + self.assertEqual(build_target.is_finished(), False) + + # If all the builds are finished with success, the build target is also + # finished with success. Check it is running until the last one... + build_ids = list(build_target.builds.keys()) + for build_id in build_ids[:-1]: + build_target.update_build_status(build_id, BuildStatus.Success) + self.assertEqual( + build_target.builds[build_id].status, + BuildStatus.Success) + self.assertEqual(build_target.status(), BuildStatus.Running) + self.assertEqual(build_target.is_finished(), False) + + # ... which will change the state to finished/success. + build_id = build_ids[-1] + build_target.update_build_status(build_id, BuildStatus.Success) + self.assertEqual( + build_target.builds[build_id].status, + BuildStatus.Success) + self.assertEqual(build_target.status(), BuildStatus.Success) + self.assertEqual(build_target.is_finished(), True) + + # If a single build fails, the build target should fail + build_target.update_build_status(3, BuildStatus.Failure) + self.assertEqual(build_target.builds[3].status, BuildStatus.Failure) + self.assertEqual(build_target.status(), BuildStatus.Failure) + self.assertEqual(build_target.is_finished(), True) + + # All the builds are finished and successful excepted one which remains + # queued: the build target should be running and not finished + build_target.update_build_status(3, BuildStatus.Queued) + self.assertEqual(build_target.builds[3].status, BuildStatus.Queued) + self.assertEqual(build_target.status(), BuildStatus.Running) + self.assertEqual(build_target.is_finished(), False) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_endpoint_backportcheck.py b/contrib/buildbot/test/test_endpoint_backportcheck.py new file mode 100755 index 000000000..3e9d48043 --- /dev/null +++ b/contrib/buildbot/test/test_endpoint_backportcheck.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import mock +import unittest + +from test.abcbot_fixture import ABCBotFixture +import test.mocks.phabricator + + +class EndpointBackportcheckTestCase(ABCBotFixture): + def test_backportCheck_happyPath(self): + self.phab.differential.revision.search.return_value = test.mocks.phabricator.Result([{ + 'id': '1234', + 'fields': { + 'summary': 'This is a test summary' + }, + }]) + + response = self.post_json_with_hmac( + '/backportCheck', self.headers, {'object': {'phid': '1234'}}) + assert response.status_code == 200 + self.phab.differential.revision.search.assert_called_with( + constraints={"phids": ['1234']}) + self.phab.differential.revision.edit.assert_not_called() + + def test_backportCheck_invalid_json(self): + response = self.post_data_with_hmac( + '/backportCheck', self.headers, "not: a valid json") + self.assertEqual(response.status_code, 415) + + def test_backportCheck_hasNoPRs(self): + # Despite potential matches for linking PRs, the phab API should not be + # called to update the summary, even if the result would be the same. + self.phab.differential.revision.search.return_value = test.mocks.phabricator.Result([{ + 'id': '1234', + 'fields': { + 'summary': "This is a test summary `Ignore this backport PR2345` some text.\n" + "Some text ```Ignore this PR3456``` Some more text.\n" + "```\nPR4567 in a multi-line code block\nPR5678 in the same code block\n```\n" + " Ignore this indented PR4567" + # Note that short numbered PRs are much more common when referencing non-bitcoin PRs, + # so we'll ignore them for now. + "Ignore short numbered PRs: PR123" + # But we do support secp256k1 PRs with 2-3 digits, so make + # sure they're also ignored properly + "This is a test summary `Ignore this secp256k1 backport PR234` some text.\n" + "Some text ```Ignore this secp256k1 PR345``` Some more text.\n" + "```\nsecp256k1 PR456 in a multi-line code block\nsecp256k1 PR567 in the same code block\n```\n" + " Ignore this indented secp256k1 PR456" + "Ignore long numbered PRs for secp256k1: PR1234" + "Ignore short numbered PRs for secp256k1: PR1", + }, + }]) + + response = self.post_json_with_hmac( + '/backportCheck', self.headers, {'object': {'phid': '1234'}}) + assert response.status_code == 200 + self.phab.differential.revision.search.assert_called_with( + constraints={'phids': ['1234']}) + self.phab.differential.revision.edit.assert_not_called() + + def test_backportCheck_hasPRs(self): + self.phab.differential.revision.search.return_value = test.mocks.phabricator.Result([{ + 'id': '1234', + 'fields': { + 'summary': "This is a test summary\n" + # Bitcoin Core links + "Backport of Core PR2345 and PR34567\n" + "PR6789 outside of a code block `PR4567 inside a code block`\n" + "```PR4567 in a single-line code block```\n" + "```\nPR4567 in a multi-line code block\n```\n" + " PR4567 in a code block using indentation\n" + "Another backport PR567890\n" + # secp256k1 links + "Backport of Secp256k1 PR23 and PR345\n" + "SECP256K1 PR678 outside of a code block `secp256k1 PR456 inside a code block`\n" + "```secp256k1 PR456 in a single-line code block```\n" + "```\nsecp256k1 PR456 in a multi-line code block\n```\n" + " secp256k1 PR456 in a code block using indentation\n" + "Another secp backport PR567", + }, + }]) + + response = self.post_json_with_hmac( + '/backportCheck', self.headers, {'object': {'phid': '1234'}}) + assert response.status_code == 200 + self.phab.differential.revision.search.assert_called_with( + constraints={'phids': ['1234']}) + calls = [mock.call(transactions=[{ + "type": "summary", + "value": "This is a test summary\n" + # Bitcoin Core links + "Backport of Core [[https://github.com/bitcoin/bitcoin/pull/2345 | PR2345]] and " + "[[https://github.com/bitcoin/bitcoin/pull/34567 | PR34567]]\n" + "[[https://github.com/bitcoin/bitcoin/pull/6789 | PR6789]] outside of a code block `PR4567 inside a code block`\n" + "```PR4567 in a single-line code block```\n" + "```\nPR4567 in a multi-line code block\n```\n" + " PR4567 in a code block using indentation\n" + "Another backport [[https://github.com/bitcoin/bitcoin/pull/567890 | PR567890]]\n" + # secp256k1 links + "Backport of Secp256k1 [[https://github.com/bitcoin-core/secp256k1/pull/23 | PR23]] and " + "[[https://github.com/bitcoin-core/secp256k1/pull/345 | PR345]]\n" + "SECP256K1 [[https://github.com/bitcoin-core/secp256k1/pull/678 | PR678]] outside of a code block `secp256k1 PR456 inside a code block`\n" + "```secp256k1 PR456 in a single-line code block```\n" + "```\nsecp256k1 PR456 in a multi-line code block\n```\n" + " secp256k1 PR456 in a code block using indentation\n" + "Another secp backport [[https://github.com/bitcoin-core/secp256k1/pull/567 | PR567]]", + }], objectIdentifier='1234'), mock.call(transactions=[{ + "type": "comment", + "value": "[Bot Message]\n" + "One or more PR numbers were detected in the summary.\n" + "Links to those PRs have been inserted into the summary for reference.", + }], objectIdentifier='1234')] + self.phab.differential.revision.edit.assert_has_calls( + calls, any_order=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_endpoint_build.py b/contrib/buildbot/test/test_endpoint_build.py new file mode 100755 index 000000000..3c3a554a4 --- /dev/null +++ b/contrib/buildbot/test/test_endpoint_build.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import json +import requests +import unittest + +from test.abcbot_fixture import ABCBotFixture +import test.mocks.teamcity +from testutil import AnyWith + + +class buildRequestQuery(): + def __init__(self): + self.buildTypeId = 'test-build-type-id' + self.ref = 'refs/heads/master' + self.PHID = 'buildPHID' + + def __str__(self): + return "?{}".format("&".join("{}={}".format(key, value) + for key, value in self.__dict__.items())) + + +class EndpointBuildTestCase(ABCBotFixture): + def test_build(self): + data = buildRequestQuery() + triggerBuildResponse = test.mocks.teamcity.buildInfo( + test.mocks.teamcity.buildInfo_changes( + ['test-change']), buildqueue=True) + self.teamcity.session.send.return_value = triggerBuildResponse + response = self.app.post('/build{}'.format(data), headers=self.headers) + assert response.status_code == 200 + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': 'https://teamcity.test/app/rest/buildQueue', + 'body': json.dumps({ + 'branchName': 'refs/heads/master', + 'buildType': { + 'id': 'test-build-type-id', + }, + 'properties': { + 'property': [{ + 'name': 'env.harborMasterTargetPHID', + 'value': 'buildPHID', + }], + }, + }), + })) + + def test_build_withAbcBuildName(self): + data = buildRequestQuery() + data.abcBuildName = 'build-diff' + triggerBuildResponse = test.mocks.teamcity.buildInfo( + test.mocks.teamcity.buildInfo_changes( + ['test-change']), buildqueue=True) + self.teamcity.session.send.return_value = triggerBuildResponse + response = self.app.post('/build{}'.format(data), headers=self.headers) + assert response.status_code == 200 + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': 'https://teamcity.test/app/rest/buildQueue', + 'body': json.dumps({ + 'branchName': 'refs/heads/master', + 'buildType': { + 'id': 'test-build-type-id', + }, + 'properties': { + 'property': [{ + 'name': 'env.ABC_BUILD_NAME', + 'value': 'build-diff', + }, { + 'name': 'env.harborMasterTargetPHID', + 'value': 'buildPHID', + }], + }, + }), + })) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_endpoint_buildDiff.py b/contrib/buildbot/test/test_endpoint_buildDiff.py new file mode 100755 index 000000000..5f1772f74 --- /dev/null +++ b/contrib/buildbot/test/test_endpoint_buildDiff.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + + +import json +import mock +import requests +import unittest +from unittest.mock import call + +from build import Build, BuildStatus +from test.abcbot_fixture import ABCBotFixture +import test.mocks.teamcity +from testutil import AnyWith + + +class buildDiffRequestQuery(): + def __init__(self): + self.stagingRef = "refs/tags/phabricator/diff/1234" + self.targetPHID = "PHID-HMBT-123456" + + def __str__(self): + return "?{}".format("&".join("{}={}".format(key, value) + for key, value in self.__dict__.items())) + + +class EndpointBuildDiffTestCase(ABCBotFixture): + def test_buildDiff(self): + data = buildDiffRequestQuery() + + def set_build_configuration(builds): + config = { + "builds": { + } + } + for build in builds: + config["builds"][build.name] = { + "runOnDiff": True + } + self.phab.get_file_content_from_master = mock.Mock() + self.phab.get_file_content_from_master.return_value = json.dumps( + config) + + def call_buildDiff(builds): + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.buildInfo(build_id=build.build_id, buildqueue=True) for build in builds + ] + + response = self.app.post( + '/buildDiff{}'.format(data), + headers=self.headers) + assert response.status_code == 200 + + self.phab.get_file_content_from_master.assert_called() + + expected_calls = [ + call(AnyWith(requests.PreparedRequest, { + "url": "https://teamcity.test/app/rest/buildQueue", + "body": json.dumps({ + "branchName": data.stagingRef, + "buildType": { + "id": "BitcoinABC_BitcoinAbcStaging", + }, + 'properties': { + 'property': [ + { + 'name': 'env.ABC_BUILD_NAME', + 'value': build.name, + }, + { + 'name': 'env.harborMasterTargetPHID', + 'value': data.targetPHID, + }, + ], + }, + }), + })) + for build in builds + ] + self.teamcity.session.send.assert_has_calls( + expected_calls, any_order=True) + self.teamcity.session.send.reset_mock() + + # No diff to run + builds = [] + set_build_configuration(builds) + call_buildDiff(builds) + self.teamcity.session.send.assert_not_called() + + # Single diff + builds.append(Build(1, BuildStatus.Queued, "build-1")) + set_build_configuration(builds) + call_buildDiff(builds) + + # Lot of builds + builds = [Build(i, BuildStatus.Queued, "build-{}".format(i)) + for i in range(10)] + set_build_configuration(builds) + call_buildDiff(builds) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_endpoint_getCurrentUser.py b/contrib/buildbot/test/test_endpoint_getCurrentUser.py new file mode 100755 index 000000000..b51c69082 --- /dev/null +++ b/contrib/buildbot/test/test_endpoint_getCurrentUser.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from test.abcbot_fixture import ABCBotFixture, TEST_USER +import unittest + + +class EndpointGetCurrentUserTestCase(ABCBotFixture): + def test_currentUser(self): + rv = self.app.get('/getCurrentUser', headers=self.headers) + assert rv.data == TEST_USER.encode() + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_endpoint_land.py b/contrib/buildbot/test/test_endpoint_land.py new file mode 100755 index 000000000..520ab7575 --- /dev/null +++ b/contrib/buildbot/test/test_endpoint_land.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import json +import requests +import unittest + +from test.abcbot_fixture import ABCBotFixture +import test.mocks.fixture +import test.mocks.teamcity +from testutil import AnyWith + + +class landRequestData(test.mocks.fixture.MockData): + def __init__(self): + self.revision = 'D1234' + self.conduitToken = 'U2FsdGVkX1/RI0AAAAAAAF46wjo3lSAxj1d1iqqkxks=' + self.committerName = 'User Name' + self.committerEmail = 'user@bitcoinabc.org' + + +class EndpointLandTestCase(ABCBotFixture): + def test_land_happyPath(self): + data = landRequestData() + triggerBuildResponse = test.mocks.teamcity.buildInfo( + test.mocks.teamcity.buildInfo_changes(['test-change'])) + self.teamcity.session.send.return_value = triggerBuildResponse + response = self.app.post('/land', headers=self.headers, json=data) + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': 'https://teamcity.test/app/rest/buildQueue', + 'body': json.dumps({ + 'branchName': 'master', + 'buildType': { + 'id': 'BitcoinAbcLandBot', + }, + 'properties': { + 'property': [{ + 'name': 'env.ABC_REVISION', + 'value': 'D1234', + }, { + 'name': 'env.ABC_CONDUIT_TOKEN', + 'value': 'U2FsdGVkX1/RI0AAAAAAAF46wjo3lSAxj1d1iqqkxks=', + }, { + 'name': 'env.ABC_COMMITTER_NAME', + 'value': 'User Name', + }, { + 'name': 'env.ABC_COMMITTER_EMAIL', + 'value': 'user@bitcoinabc.org', + }, { + 'name': 'env.harborMasterTargetPHID', + 'value': 'UNRESOLVED', + }], + }, + }), + })) + assert response.status_code == 200 + assert response.get_json() == json.loads(triggerBuildResponse.content) + + def test_land_invalid_json(self): + data = "not: a valid json" + response = self.app.post('/land', headers=self.headers, data=data) + self.assertEqual(response.status_code, 415) + + def test_land_missingArguments(self): + # Test otherwise valid requests with each required argument missing. + # All of them should fail with status code 400. + requiredArgs = [ + 'revision', + 'conduitToken', + 'committerName', + 'committerEmail', + ] + for arg in requiredArgs: + data = landRequestData() + setattr(data, arg, '') + response = self.app.post('/land', headers=self.headers, json=data) + assert response.status_code == 400 + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_endpoint_status.py b/contrib/buildbot/test/test_endpoint_status.py new file mode 100755 index 000000000..057b7f9f5 --- /dev/null +++ b/contrib/buildbot/test/test_endpoint_status.py @@ -0,0 +1,1642 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2019 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import json +import mock +import requests +import unittest +from urllib.parse import urljoin + +from build import BuildStatus +from phabricator_wrapper import BITCOIN_ABC_REPO +from server import BADGE_TC_BASE +from teamcity import BuildInfo +from testutil import AnyWith +from test.abcbot_fixture import ABCBotFixture +import test.mocks.fixture +import test.mocks.phabricator +import test.mocks.teamcity +from test.mocks.teamcity import DEFAULT_BUILD_ID, TEAMCITY_CI_USER + + +class statusRequestData(test.mocks.fixture.MockData): + def __init__(self): + self.buildName = 'build-name' + self.project = 'bitcoin-abc-test' + self.buildId = DEFAULT_BUILD_ID + self.buildTypeId = 'build-type-id' + self.buildResult = 'success' + self.revision = 'commitHash' + self.branch = 'refs/heads/master' + self.buildTargetPHID = 'buildTargetPHID' + + def __setattr__(self, name, value): + super().__setattr__(name, value) + if name in ['buildId', 'buildTypeId']: + self.buildURL = urljoin( + test.mocks.teamcity.TEAMCITY_BASE_URL, + 'viewLog.html?buildTypeId={}&buildId={}'.format( + getattr(self, 'buildTypeId', ''), + getattr(self, 'buildId', ''))) + + +class EndpointStatusTestCase(ABCBotFixture): + def setUp(self): + super().setUp() + self.phab.get_file_content_from_master = mock.Mock() + self.phab.get_file_content_from_master.return_value = json.dumps({}) + + self.phab.set_text_panel_content = mock.Mock() + + self.teamcity.getBuildInfo = mock.Mock() + self.configure_build_info() + self.teamcity.get_coverage_summary = mock.Mock() + self.teamcity.get_coverage_summary.return_value = None + + self.travis.get_branch_status = mock.Mock() + self.travis.get_branch_status.return_value = BuildStatus.Success + + def setup_master_failureAndTaskDoesNotExist(self, latestCompletedBuildId=DEFAULT_BUILD_ID, + numRecentFailedBuilds=0, numCommits=1, + userSearchFields=None): + if userSearchFields is None: + userSearchFields = {} + + self.phab.maniphest.edit.return_value = { + 'object': { + 'id': '890', + 'phid': 'PHID-TASK-890', + }, + } + + recentBuilds = [] if numRecentFailedBuilds == 0 else [ + {'status': 'FAILURE'}, {'status': 'SUCCESS'}] * numRecentFailedBuilds + self.teamcity.session.send.side_effect = [ + # Build failures + test.mocks.teamcity.Response(), + # Latest completed build + test.mocks.teamcity.Response(json.dumps({ + 'build': [{ + 'id': latestCompletedBuildId, + }], + })), + test.mocks.teamcity.Response(json.dumps({ + 'build': recentBuilds, + })), + ] + + commits = [] + for i in range(numCommits): + commitId = 8000 + i + commits.append({ + 'phid': 'PHID-COMMIT-{}'.format(commitId), + 'fields': { + 'identifier': 'deadbeef0000011122233344455566677788{}'.format(commitId) + }, + }) + self.phab.diffusion.commit.search.return_value = test.mocks.phabricator.Result( + commits) + + revisionSearchResult = test.mocks.phabricator.differential_revision_search_result( + total=numCommits) + + revisions = [] + for i in range(numCommits): + revisions.append({ + 'sourcePHID': 'PHID-COMMIT-{}'.format(8000 + i), + 'destinationPHID': revisionSearchResult.data[i]['phid'], + }) + self.phab.edge.search.return_value = test.mocks.phabricator.Result( + revisions) + + self.phab.differential.revision.search.return_value = revisionSearchResult + self.phab.user.search.return_value = test.mocks.phabricator.Result([{ + 'id': '5678', + 'phid': revisionSearchResult.data[0]['fields']['authorPHID'], + 'fields': userSearchFields, + }]) + + def configure_build_info(self, **kwargs): + self.teamcity.getBuildInfo.return_value = BuildInfo.fromSingleBuildResponse( + json.loads(test.mocks.teamcity.buildInfo(**kwargs).content) + ) + + def test_status_invalid_json(self): + data = "not: a valid json" + response = self.app.post('/status', headers=self.headers, data=data) + self.assertEqual(response.status_code, 415) + + def test_status_noData(self): + response = self.app.post('/status', headers=self.headers) + assert response.status_code == 415 + self.phab.harbormaster.createartifact.assert_not_called() + + def test_status_unresolved(self): + data = statusRequestData() + data.branch = 'UNRESOLVED' + data.buildTargetPHID = 'UNRESOLVED' + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 400 + self.phab.harbormaster.createartifact.assert_not_called() + + def test_status_ignoredBuild(self): + data = statusRequestData() + data.buildTypeId = 'build-name__BOTIGNORE' + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.harbormaster.createartifact.assert_not_called() + + def test_status_master(self): + data = statusRequestData() + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.buildInfo_automatedBuild(), + test.mocks.teamcity.Response(json.dumps({ + 'build': [{ + 'id': DEFAULT_BUILD_ID, + }], + })), + ] + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_not_called() + self.slackbot.client.chat_postMessage.assert_not_called() + + def test_status_master_resolveBrokenBuildTask_masterGreen(self): + def setupMockResponses(): + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.buildInfo_automatedBuild(), + test.mocks.teamcity.Response(json.dumps({ + 'build': [{ + 'id': DEFAULT_BUILD_ID, + }], + })), + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(), + ] + self.phab.maniphest.search.return_value = test.mocks.phabricator.Result([{ + 'id': '123', + 'phid': 'PHID-TASK-123', + }]) + self.phab.maniphest.edit.return_value = { + 'object': { + 'id': '123', + 'phid': 'PHID-TASK-123', + }, + } + + data = statusRequestData() + data.buildResult = 'failure' + setupMockResponses() + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + # Master should be marked red + + data = statusRequestData() + setupMockResponses() + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_called_with(transactions=[{ + 'type': 'status', + 'value': 'resolved', + }], objectIdentifier='PHID-TASK-123') + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-dev-channel', + text="Master is green again.") + + def test_status_master_resolveBrokenBuildTask_masterStillRed(self): + data = statusRequestData() + + self.configure_build_info( + triggered=test.mocks.teamcity.buildInfo_triggered( + triggerType='user', username=TEAMCITY_CI_USER) + ) + + # Check build failure + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(json.dumps({ + 'build': [{ + 'id': DEFAULT_BUILD_ID, + }], + })), + test.mocks.teamcity.Response(json.dumps({ + 'problemOccurrence': [{ + 'build': { + 'buildTypeId': 'build-type', + }, + }], + })), + test.mocks.teamcity.Response(), + ] + self.phab.maniphest.search.return_value = test.mocks.phabricator.Result([{ + 'id': '123', + 'phid': 'PHID-TASK-123', + }]) + self.phab.maniphest.edit.return_value = { + 'object': { + 'id': '123', + 'phid': 'PHID-TASK-123', + }, + } + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_called_with(transactions=[{ + 'type': 'status', + 'value': 'resolved', + }], objectIdentifier='PHID-TASK-123') + self.slackbot.client.chat_postMessage.assert_not_called() + + # Check test failure + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(json.dumps({ + 'build': [{ + 'id': DEFAULT_BUILD_ID, + }], + })), + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(json.dumps({ + 'testOccurrence': [{ + 'build': { + 'buildTypeId': 'build-type', + }, + }], + })), + ] + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_called_with(transactions=[{ + 'type': 'status', + 'value': 'resolved', + }], objectIdentifier='PHID-TASK-123') + self.slackbot.client.chat_postMessage.assert_not_called() + + def test_status_master_resolveBrokenBuild_outOfOrderBuilds(self): + data = statusRequestData() + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.buildInfo_automatedBuild(), + test.mocks.teamcity.Response(json.dumps({ + 'build': [{ + # Another build of the same type that was started after this build + # has already completed. Do not treat master as green/fixed based + # on this build, since the most recent build may have + # failed. + 'id': 234567, + }], + })), + ] + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_not_called() + self.slackbot.client.chat_postMessage.assert_not_called() + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/builds", + { + "locator": "buildType:build-type-id", + "fields": "build(id)", + "count": 1, + } + ) + })) + + def test_status_infraFailure(self): + # Test an infra failure on master + data = statusRequestData() + data.buildResult = 'failure' + + with open(self.data_dir / 'testlog_infrafailure.zip', 'rb') as f: + buildLog = f.read() + + def setupTeamcity(): + self.configure_build_info( + triggered=test.mocks.teamcity.buildInfo_triggered( + triggerType='user', username=TEAMCITY_CI_USER) + ) + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(json.dumps({ + 'problemOccurrence': [{ + 'id': 'id:2500,build:(id:56789)', + }], + })), + test.mocks.teamcity.Response( + status_code=requests.codes.not_found), + test.mocks.teamcity.Response(buildLog), + ] + + setupTeamcity() + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_not_called() + + def verifyInfraChannelMessage(): + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#infra-support-channel', + text=" There was an infrastructure failure in 'build-name': {}".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + )) + verifyInfraChannelMessage() + + # Test an infra failure on a revision + data = statusRequestData() + data.branch = 'phabricator/diff/456' + data.buildResult = 'failure' + setupTeamcity() + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([{ + 'id': '456', + 'fields': { + 'revisionPHID': '789' + }, + }]) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_called_with(transactions=[{ + "type": "comment", + "value": "(IMPORTANT) The build failed due to an unexpected infrastructure outage. " + "The administrators have been notified to investigate. Sorry for the inconvenience.", + }], objectIdentifier='789') + self.phab.maniphest.edit.assert_not_called() + verifyInfraChannelMessage() + + def test_status_master_failureAndTaskDoesNotExist_outOfOrderBuilds(self): + data = statusRequestData() + data.buildResult = 'failure' + + # Another build of the same type that was started after this build + # has already completed. Do not treat master as red/broken based + # on this build, since the most recent build may have succeeded. + self.setup_master_failureAndTaskDoesNotExist( + latestCompletedBuildId=234567) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_not_called() + self.slackbot.client.chat_postMessage.assert_not_called() + + def test_status_master_failureAndTaskDoesNotExist_authorDefaultName(self): + data = statusRequestData() + data.buildResult = 'failure' + + self.setup_master_failureAndTaskDoesNotExist(userSearchFields={ + 'username': 'author-phab-username', + 'custom.abc:slack-username': '', + }) + self.slackbot.client.users_list.return_value = test.mocks.slackbot.users_list( + total=2) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + maniphestEditCalls = [mock.call(transactions=[{ + "type": "title", + "value": "Build build-name is broken.", + }, { + "type": "priority", + "value": "unbreak", + }, { + "type": "description", + "value": "[[ {} | build-name ]] is broken on branch 'refs/heads/master'\n\nAssociated commits:\nrABCdeadbeef00000111222333444555666777888000".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + ), + }])] + self.phab.maniphest.edit.assert_has_calls( + maniphestEditCalls, any_order=False) + + self.phab.diffusion.commit.search.assert_called_with(constraints={ + "repositories": [BITCOIN_ABC_REPO], + "identifiers": ['deadbeef00000111222333444555666777888000'], + }) + self.phab.edge.search.assert_called_with( + types=['commit.revision'], sourcePHIDs=['PHID-COMMIT-8000']) + self.phab.differential.revision.search.assert_called_with( + constraints={"phids": ['PHID-DREV-1000']}) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-dev-channel', + text="Committer: author-phab-username\n" + "Build 'build-name' appears to be broken: {}\n" + "Task: https://reviews.bitcoinabc.org/T890\n" + "Diff: https://reviews.bitcoinabc.org/D{}".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildId": DEFAULT_BUILD_ID, + } + ), + test.mocks.phabricator.DEFAULT_REVISION_ID, + ) + ) + + def test_status_master_failureAndTaskDoesNotExist_authorSlackUsername( + self): + data = statusRequestData() + data.buildResult = 'failure' + + slackUserProfile = test.mocks.slackbot.userProfile( + {'real_name': 'author-slack-username'}) + slackUser = test.mocks.slackbot.user( + userId='U8765', profile=slackUserProfile) + self.setup_master_failureAndTaskDoesNotExist(userSearchFields={ + 'username': 'author-phab-username', + 'custom.abc:slack-username': 'author-slack-username', + }) + self.slackbot.client.users_list.return_value = test.mocks.slackbot.users_list( + total=2, initialUsers=[slackUser]) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + maniphestEditCalls = [mock.call(transactions=[{ + "type": "title", + "value": "Build build-name is broken.", + }, { + "type": "priority", + "value": "unbreak", + }, { + "type": "description", + "value": "[[ {} | build-name ]] is broken on branch 'refs/heads/master'\n\nAssociated commits:\nrABCdeadbeef00000111222333444555666777888000".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + ), + }])] + self.phab.maniphest.edit.assert_has_calls( + maniphestEditCalls, any_order=False) + + self.phab.diffusion.commit.search.assert_called_with(constraints={ + "repositories": [BITCOIN_ABC_REPO], + "identifiers": ['deadbeef00000111222333444555666777888000'], + }) + self.phab.edge.search.assert_called_with( + types=['commit.revision'], sourcePHIDs=['PHID-COMMIT-8000']) + self.phab.differential.revision.search.assert_called_with( + constraints={"phids": ['PHID-DREV-1000']}) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-dev-channel', + text="Committer: <@U8765>\n" + "Build 'build-name' appears to be broken: {}\n" + "Task: https://reviews.bitcoinabc.org/T890\n" + "Diff: https://reviews.bitcoinabc.org/D{}".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildId": DEFAULT_BUILD_ID, + } + ), + test.mocks.phabricator.DEFAULT_REVISION_ID, + ) + ) + + def test_status_master_failureAndTaskDoesNotExist_scheduledBuild(self): + data = statusRequestData() + data.buildResult = 'failure' + + self.configure_build_info( + triggered=test.mocks.teamcity.buildInfo_triggered( + triggerType='schedule') + ) + self.setup_master_failureAndTaskDoesNotExist() + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + maniphestEditCalls = [mock.call(transactions=[{ + "type": "title", + "value": "Build build-name is broken.", + }, { + "type": "priority", + "value": "unbreak", + }, { + "type": "description", + "value": "[[ {} | build-name ]] is broken on branch 'refs/heads/master'\n\nAssociated commits:\nrABCdeadbeef00000111222333444555666777888000".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + ), + }])] + self.phab.maniphest.edit.assert_has_calls( + maniphestEditCalls, any_order=False) + + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-dev-channel', + text="Scheduled build 'build-name' appears to be broken: {}\n" + "Task: https://reviews.bitcoinabc.org/T890".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildId": DEFAULT_BUILD_ID, + } + ) + )) + + def test_status_master_failureAndTaskDoesNotExist_multipleChanges(self): + data = statusRequestData() + data.buildResult = 'failure' + + self.configure_build_info( + changes=test.mocks.teamcity.buildInfo_changes([ + 'deadbeef00000111222333444555666777888000', + 'deadbeef00000111222333444555666777888001']), + triggered=test.mocks.teamcity.buildInfo_triggered(triggerType='user', + username=test.mocks.teamcity.TEAMCITY_CI_USER), + ) + + self.setup_master_failureAndTaskDoesNotExist( + numCommits=2, userSearchFields={ + 'username': 'author-phab-username', + 'custom.abc:slack-username': '', + }) + self.slackbot.client.users_list.return_value = test.mocks.slackbot.users_list( + total=2) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + maniphestEditCalls = [mock.call(transactions=[{ + "type": "title", + "value": "Build build-name is broken.", + }, { + "type": "priority", + "value": "unbreak", + }, { + "type": "description", + "value": "[[ {} | build-name ]] is broken on branch 'refs/heads/master'\n\nAssociated commits:\nrABCdeadbeef00000111222333444555666777888000\nrABCdeadbeef00000111222333444555666777888001".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + ), + }])] + self.phab.maniphest.edit.assert_has_calls( + maniphestEditCalls, any_order=False) + + self.phab.diffusion.commit.search.assert_called_with(constraints={ + "repositories": [BITCOIN_ABC_REPO], + "identifiers": ['deadbeef00000111222333444555666777888000', 'deadbeef00000111222333444555666777888001'], + }) + self.phab.edge.search.assert_called_with( + types=['commit.revision'], sourcePHIDs=[ + 'PHID-COMMIT-8000', 'PHID-COMMIT-8001']) + self.phab.differential.revision.search.assert_called_with( + constraints={"phids": ['PHID-DREV-1000', 'PHID-DREV-1001']}) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-dev-channel', + text="Committer: author-phab-username\n" + "Build 'build-name' appears to be broken: {}\n" + "Task: https://reviews.bitcoinabc.org/T890\n" + "Diff: https://reviews.bitcoinabc.org/D{}".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildId": DEFAULT_BUILD_ID, + } + ), + test.mocks.phabricator.DEFAULT_REVISION_ID, + )) + + def test_status_master_failureAndTaskDoesNotExist_firstFlakyFailure(self): + self.teamcity.setMockTime(1590000000) + data = statusRequestData() + data.buildResult = 'failure' + + self.setup_master_failureAndTaskDoesNotExist(numRecentFailedBuilds=2) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/builds", + { + "locator": "buildType:{},sinceDate:{}".format(data.buildTypeId, + self.teamcity.formatTime(1590000000 - 60 * 60 * 24 * 5)), + "fields": "build", + } + ) + })) + + self.phab.maniphest.edit.assert_not_called() + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-dev-channel', + text="Build 'build-name' appears to be flaky: {}".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildId": DEFAULT_BUILD_ID, + } + )) + ) + + def test_status_master_failureAndTaskDoesNotExist_successiveFlakyFailures( + self): + self.teamcity.setMockTime(1590000000) + data = statusRequestData() + data.buildResult = 'failure' + + self.setup_master_failureAndTaskDoesNotExist(numRecentFailedBuilds=3) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/builds", + { + "locator": "buildType:{},sinceDate:{}".format(data.buildTypeId, + self.teamcity.formatTime(1590000000 - 60 * 60 * 24 * 5)), + "fields": "build", + } + ) + })) + + self.phab.maniphest.edit.assert_not_called() + self.slackbot.client.chat_postMessage.assert_not_called() + + def test_status_master_failureAndTaskExists(self): + data = statusRequestData() + data.buildResult = 'failure' + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.buildInfo_automatedBuild(), + test.mocks.teamcity.Response(json.dumps({ + 'build': [{ + 'id': DEFAULT_BUILD_ID, + }], + })), + test.mocks.teamcity.Response(), + ] + + self.phab.maniphest.search.return_value = test.mocks.phabricator.Result([{ + 'id': '123', + }]) + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_not_called() + self.phab.maniphest.edit.assert_not_called() + + def test_status_revision_happyPath(self): + data = statusRequestData() + data.branch = 'phabricator/diff/456' + + self.configure_build_info( + properties=test.mocks.teamcity.buildInfo_properties(propsList=[{ + 'name': 'env.ABC_BUILD_NAME', + 'value': 'build-diff', + }]) + ) + + self.phab.differential.revision.edit = mock.Mock() + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([{ + 'id': '456', + 'fields': { + 'revisionPHID': '789' + }, + }]) + self.phab.differential.revision.search.return_value = test.mocks.phabricator.differential_revision_search_result() + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + + def test_status_revision_buildFailedWithoutDetails_OS_NAME(self): + data = statusRequestData() + data.buildResult = 'failure' + data.branch = 'phabricator/diff/456' + + self.configure_build_info( + properties=test.mocks.teamcity.buildInfo_properties(propsList=[{ + 'name': 'env.OS_NAME', + 'value': 'linux', + }]), + ) + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(), + ] + + self.phab.differential.revision.edit = mock.Mock() + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([{ + 'id': '456', + 'fields': { + 'revisionPHID': '789' + }, + }]) + self.phab.differential.revision.search.return_value = test.mocks.phabricator.differential_revision_search_result() + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.phab.differential.revision.edit.assert_called_with(transactions=[{ + "type": "comment", + "value": "(IMPORTANT) Build [[{} | build-name (linux)]] failed.".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + ), + }], objectIdentifier='789') + + def test_status_revision_buildFailedWithoutDetails(self): + data = statusRequestData() + data.buildResult = 'failure' + data.branch = 'phabricator/diff/789' + + self.phab.differential.revision.edit = mock.Mock() + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([{ + 'id': '789', + 'fields': { + 'revisionPHID': '123' + }, + }]) + self.phab.differential.revision.search.return_value = test.mocks.phabricator.differential_revision_search_result() + + self.configure_build_info( + properties=test.mocks.teamcity.buildInfo_properties(propsList=[{ + 'name': 'env.ABC_BUILD_NAME', + 'value': 'build-diff', + }]) + ) + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(), + ] + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/testOccurrences", + { + "locator": "build:(id:{}),status:FAILURE".format(DEFAULT_BUILD_ID), + "fields": "testOccurrence(id,details,name)", + } + ) + })) + self.phab.differential.revision.edit.assert_called_with(transactions=[{ + "type": "comment", + "value": "(IMPORTANT) Build [[{} | build-name (build-diff)]] failed.".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + ), + }], objectIdentifier='123') + + def test_status_revision_buildFailedWithDetails(self): + data = statusRequestData() + data.buildResult = 'failure' + data.branch = 'phabricator/diff/789' + + self.phab.differential.revision.edit = mock.Mock() + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([{ + 'id': '789', + 'fields': { + 'revisionPHID': '123' + }, + }]) + self.phab.differential.revision.search.return_value = test.mocks.phabricator.differential_revision_search_result() + + with open(self.data_dir / 'testlog.zip', 'rb') as f: + buildLog = f.read() + with open(self.data_dir / 'testlog.output.txt', 'r', encoding='utf-8') as f: + expectedLogOutput = f.read() + + self.configure_build_info( + properties=test.mocks.teamcity.buildInfo_properties(propsList=[{ + 'name': 'env.ABC_BUILD_NAME', + 'value': 'build-diff', + }]) + ) + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(json.dumps({ + 'problemOccurrence': [{ + 'id': 'id:2500,build:(id:56789)', + # The first build failure: + 'details': 'Process exited with code 2 (Step: Command Line)', + }, { + 'id': 'id:2620,build:(id:56789)', + # A line from the log that appears after the first failure: + 'details': 'No reports found for paths:', + }], + })), + test.mocks.teamcity.Response(status_code=requests.codes.not_found), + test.mocks.teamcity.Response(buildLog), + test.mocks.teamcity.Response(), + ] + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/testOccurrences", + { + "locator": "build:(id:{}),status:FAILURE".format(DEFAULT_BUILD_ID), + "fields": "testOccurrence(id,details,name)", + } + ) + })) + self.phab.differential.revision.edit.assert_called_with(transactions=[{ + "type": "comment", + "value": "(IMPORTANT) Build [[{} | build-name (build-diff)]] failed.\n\n" + "Snippet of first build failure:\n```lines=16,COUNTEREXAMPLE\n{}```".format( + self.teamcity.build_url( + "viewLog.html", + { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": DEFAULT_BUILD_ID, + }, + "footer" + ), + expectedLogOutput + ), + }], objectIdentifier='123') + + # Build log taken from the artifacts + expected_log = b'What a wonderful log !\nBut not very helpful.\nSome failure message\n' + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(json.dumps({ + 'problemOccurrence': [{ + 'id': 'id:2500,build:(id:56789)', + # The first build failure: + 'details': 'Some failure message', + }], + })), + test.mocks.teamcity.Response(expected_log), + test.mocks.teamcity.Response(), + ] + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/testOccurrences", + { + "locator": "build:(id:{}),status:FAILURE".format(DEFAULT_BUILD_ID), + "fields": "testOccurrence(id,details,name)", + } + ) + })) + self.phab.differential.revision.edit.assert_called_with(transactions=[{ + "type": "comment", + "value": "(IMPORTANT) Build [[{} | build-name (build-diff)]] failed.\n\n" + "Snippet of first build failure:\n```lines=16,COUNTEREXAMPLE\n{}```".format( + self.teamcity.build_url( + "viewLog.html", + { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": DEFAULT_BUILD_ID, + }, + "footer" + ), + expected_log.decode('utf-8') + ), + }], objectIdentifier='123') + + def test_status_revision_testsFailedWithDetails(self): + data = statusRequestData() + data.branch = 'phabricator/diff/456' + data.buildResult = 'failure' + + self.phab.differential.revision.edit = mock.Mock() + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([{ + 'id': '456', + 'fields': { + 'revisionPHID': '789' + }, + }]) + self.phab.differential.revision.search.return_value = test.mocks.phabricator.differential_revision_search_result() + + failures = [{ + 'id': 'id:2500,build:(id:{})'.format(DEFAULT_BUILD_ID), + 'details': 'stacktrace', + 'name': 'test name', + }, { + 'id': 'id:2620,build:(id:{})'.format(DEFAULT_BUILD_ID), + 'details': 'stacktrace2', + 'name': 'other test name', + }] + + self.configure_build_info( + properties=test.mocks.teamcity.buildInfo_properties(propsList=[{ + 'name': 'env.ABC_BUILD_NAME', + 'value': 'build-diff', + }]) + ) + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(), + test.mocks.teamcity.Response(json.dumps({ + 'testOccurrence': failures, + })) + ] + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/testOccurrences", + { + "locator": "build:(id:{}),status:FAILURE".format(DEFAULT_BUILD_ID), + "fields": "testOccurrence(id,details,name)", + } + ) + })) + self.phab.differential.revision.edit.assert_called_with(transactions=[{ + "type": "comment", + "value": "(IMPORTANT) Build [[{} | build-name (build-diff)]] failed.\n\n" + "Each failure log is accessible here:\n" + "[[{} | test name]]\n" + "[[{} | other test name]]".format( + self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ), + self.teamcity.build_url( + "viewLog.html", + { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": DEFAULT_BUILD_ID, + "_focus": 2500, + } + ), + self.teamcity.build_url( + "viewLog.html", + { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": DEFAULT_BUILD_ID, + "_focus": 2620, + } + ) + ), + }], objectIdentifier='789') + + def test_status_build_link_to_harbormaster(self): + data = statusRequestData() + data.buildTargetPHID = "PHID-HMBT-01234567890123456789" + + def call_build(build_id=DEFAULT_BUILD_ID, build_name=data.buildName): + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.buildInfo( + build_id=build_id, buildqueue=True), + ] + url = 'build?buildTypeId=staging&ref=refs/tags/phabricator/diffs/{}&PHID={}&abcBuildName={}'.format( + build_id, + data.buildTargetPHID, + build_name + ) + response = self.app.post(url, headers=self.headers) + assert response.status_code == 200 + + # Set the status to 'running' to prevent target removal on completion. + data.buildResult = "running" + # Add some build target or there is no harbormaster build to link. + call_build() + + def call_status_check_artifact_search(build_id=DEFAULT_BUILD_ID): + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.buildInfo_automatedBuild(), + test.mocks.teamcity.buildInfo(build_id=build_id), + ] + response = self.app.post( + '/status', headers=self.headers, json=data) + assert response.status_code == 200 + + self.phab.harbormaster.artifact.search.assert_called_with( + constraints={ + "buildTargetPHIDs": ["PHID-HMBT-01234567890123456789"], + } + ) + + def check_createartifact(build_id=DEFAULT_BUILD_ID, + build_name=data.buildName): + self.phab.harbormaster.createartifact.assert_called_with( + buildTargetPHID="PHID-HMBT-01234567890123456789", + artifactKey=build_name + "-PHID-HMBT-01234567890123456789", + artifactType="uri", + artifactData={ + "uri": self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": build_id, + } + ), + "name": build_name, + "ui.external": True, + } + ) + + # On first call the missing URL artifact should be added + call_status_check_artifact_search() + check_createartifact() + + # Furher calls to artifact.search will return our added URL artifact + artifact_search_return_value = { + "id": 123, + "phid": "PHID-HMBA-abcdefghijklmnopqrst", + "fields": { + "buildTargetPHID": "PHID-HMBT-01234567890123456789", + "artifactType": "uri", + "artifactKey": data.buildName + "-PHID-HMBT-01234567890123456789", + "isReleased": True, + "dateCreated": 0, + "dateModified": 0, + "policy": {}, + } + } + self.phab.harbormaster.artifact.search.return_value = test.mocks.phabricator.Result( + [artifact_search_return_value]) + + # Reset the call counter + self.phab.harbormaster.createartifact.reset_mock() + + # Call /status again a few time + for i in range(10): + call_status_check_artifact_search() + + # But since the artifact already exists it is not added again + self.phab.harbormaster.createartifact.assert_not_called() + + # If the artifact key is not the expected one, the link is added + artifact_search_return_value["fields"]["artifactKey"] = "unexpectedArtifactKey" + self.phab.harbormaster.artifact.search.return_value = test.mocks.phabricator.Result( + [artifact_search_return_value]) + + call_status_check_artifact_search() + check_createartifact() + + # Add a few more builds to the build target + for i in range(1, 1 + 5): + build_id = DEFAULT_BUILD_ID + i + build_name = 'build-{}'.format(i) + + data.buildName = build_name + data.buildId = build_id + data.buildTypeId = data.buildTypeId + + call_build(build_id, build_name) + + # Check the artifact is searched and add for each build + call_status_check_artifact_search(build_id) + check_createartifact(build_id, build_name) + + def test_status_landbot(self): + data = statusRequestData() + data.buildTypeId = 'BitcoinAbcLandBot' + + # Side effects are only valid once per call, so we need to set side_effect + # for every call to the endpoint. + def setupTeamcity(): + self.configure_build_info( + properties=test.mocks.teamcity.buildInfo_properties( + propsList=[{ + 'name': 'env.ABC_REVISION', + 'value': 'D1234', + }] + ) + ) + + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(), + ] + + def setupUserSearch(slackUsername): + self.phab.user.search.return_value = test.mocks.phabricator.Result([{ + 'id': '5678', + 'phid': revisionSearchResult.data[0]['fields']['authorPHID'], + 'fields': { + 'username': 'author-phab-username', + 'custom.abc:slack-username': slackUsername, + }, + }]) + + slackUserProfile = test.mocks.slackbot.userProfile( + {'real_name': 'author-slack-username'}) + slackUser = test.mocks.slackbot.user( + userId='U8765', profile=slackUserProfile) + self.slackbot.client.users_list.return_value = test.mocks.slackbot.users_list( + total=2, initialUsers=[slackUser]) + + revisionSearchResult = test.mocks.phabricator.differential_revision_search_result() + self.phab.differential.revision.search.return_value = revisionSearchResult + + expectedBuildUrl = self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": data.buildTypeId, + "buildId": DEFAULT_BUILD_ID, + } + ) + + # Test happy path + setupTeamcity() + setupUserSearch(slackUsername='author-slack-username') + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + + self.phab.differential.revision.search.assert_called_with(constraints={ + 'ids': [1234]}) + self.phab.user.search.assert_called_with( + constraints={'phids': [revisionSearchResult.data[0]['fields']['authorPHID']]}) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='U8765', + text="Successfully landed your change:\n" + "Revision: https://reviews.bitcoinabc.org/D1234\n" + "Build: {}".format(expectedBuildUrl), + ) + + # Test direct message on a landbot build failure + data.buildResult = 'failure' + setupTeamcity() + setupUserSearch(slackUsername='author-slack-username') + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + + self.phab.differential.revision.search.assert_called_with(constraints={ + 'ids': [1234]}) + self.phab.user.search.assert_called_with( + constraints={'phids': [revisionSearchResult.data[0]['fields']['authorPHID']]}) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='U8765', + text="Failed to land your change:\n" + "Revision: https://reviews.bitcoinabc.org/D1234\n" + "Build: {}".format(expectedBuildUrl), + ) + + # Test message on build failure when the author's slack username is + # unknown + setupUserSearch(slackUsername='') + setupTeamcity() + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + + self.phab.differential.revision.search.assert_called_with(constraints={ + 'ids': [1234]}) + self.phab.user.search.assert_called_with( + constraints={'phids': [revisionSearchResult.data[0]['fields']['authorPHID']]}) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-dev-channel', + text="author-phab-username: Please set your slack username in your Phabricator profile so the " + "landbot can send you direct messages: https://reviews.bitcoinabc.org/people/editprofile/5678\n" + "Failed to land your change:\n" + "Revision: https://reviews.bitcoinabc.org/D1234\n" + "Build: {}".format(expectedBuildUrl), + ) + + # Make sure no messages are sent on running status + data.buildResult = 'running' + setupTeamcity() + self.phab.differential.revision.search = mock.Mock() + self.phab.user.search = mock.Mock() + self.slackbot.client.chat_postMessage = mock.Mock() + + response = self.app.post('/status', headers=self.headers, json=data) + assert response.status_code == 200 + + self.phab.differential.revision.search.assert_not_called() + self.phab.user.search.assert_not_called() + self.slackbot.client.chat_postMessage.assert_not_called() + + def test_update_build_status_panel(self): + panel_id = 17 + + self.phab.get_file_content_from_master = mock.Mock() + self.phab.set_text_panel_content = mock.Mock() + + associated_builds = {} + self.teamcity.associate_configuration_names = mock.Mock() + self.teamcity.associate_configuration_names.return_value = associated_builds + + # List of failing build types + failing_build_type_ids = [] + # List of builds that did not complete yet + no_complete_build_type_ids = [] + + # Builds with ID 42 are failures + self.teamcity.getLatestCompletedBuild = mock.Mock() + self.teamcity.getLatestCompletedBuild.side_effect = lambda build_type_id: ( + {'id': 42} if build_type_id in failing_build_type_ids + else None if build_type_id in no_complete_build_type_ids + else {'id': DEFAULT_BUILD_ID} + ) + + build_info = BuildInfo.fromSingleBuildResponse( + json.loads(test.mocks.teamcity.buildInfo().content) + ) + + def _get_build_info(build_id): + status = BuildStatus.Failure if build_id == 42 else BuildStatus.Success + build_info['id'] = build_id + build_info['status'] = status.value.upper() + build_info['statusText'] = "Build success" if status == BuildStatus.Success else "Build failure" + return build_info + + self.teamcity.getBuildInfo.side_effect = _get_build_info + + def get_travis_panel_content(status=None): + if not status: + status = BuildStatus.Success + + return ( + '| secp256k1 ([[https://github.com/Bitcoin-ABC/secp256k1 | Github]]) | Status |\n' + '|---|---|\n' + '| [[https://travis-ci.org/github/bitcoin-abc/secp256k1 | master]] | {{image uri="https://raster.shields.io/static/v1?label=Travis build&message={}&color={}&logo=travis", alt="{}"}} |\n\n' + ).format( + status.value, + 'brightgreen' if status == BuildStatus.Success else 'red', + status.value, + ) + + def set_config_file(names_to_display, names_to_hide): + config = {"builds": {}} + builds = config["builds"] + for build_name in names_to_display: + builds[build_name] = {"hideOnStatusPanel": False} + for build_name in names_to_hide: + builds[build_name] = {"hideOnStatusPanel": True} + + self.phab.get_file_content_from_master.return_value = json.dumps( + config) + + def associate_build(name, teamcity_build_type_id=None, teamcity_build_name=None, + teamcity_project_id=None, teamcity_project_name=None): + if not teamcity_build_type_id: + teamcity_build_type_id = "{}_Type".format(name) + if not teamcity_build_name: + teamcity_build_name = "My Build {}".format(name) + if not teamcity_project_id: + teamcity_project_id = "ProjectId" + if not teamcity_project_name: + teamcity_project_name = "Project Name" + + associated_builds[name] = { + "teamcity_build_type_id": teamcity_build_type_id, + "teamcity_build_name": teamcity_build_name, + "teamcity_project_id": teamcity_project_id, + "teamcity_project_name": teamcity_project_name, + } + self.teamcity.associate_configuration_names.return_value = associated_builds + + def call_status(build_type_id, status, branch=None, + expected_status_code=None): + data = statusRequestData() + data.buildResult = status.value + data.buildTypeId = build_type_id + if branch: + data.branch = branch + + response = self.app.post( + '/status', headers=self.headers, json=data) + assert response.status_code == ( + 200 if not expected_status_code else expected_status_code) + + def assert_panel_content(content): + self.phab.set_text_panel_content.assert_called_with( + panel_id, + content + ) + + def header(project_name): + header = '| {} | Status |\n'.format(project_name) + header += '|---|---|\n' + return header + + def build_line(build_name, status=None, build_type_id=None, + teamcity_build_name=None): + if not status: + status = BuildStatus.Success + if not build_type_id: + build_type_id = "{}_Type".format(build_name) + if not teamcity_build_name: + teamcity_build_name = "My Build {}".format(build_name) + + url = self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": build_type_id, + "buildId": "lastFinished" + } + ) + status_message = "Build failure" if status == BuildStatus.Failure else status.value + badge_url = BADGE_TC_BASE.get_badge_url( + message=status_message, + color=( + 'lightgrey' if status == BuildStatus.Unknown + else 'brightgreen' if status == BuildStatus.Success + else 'red' + ), + ) + return '| [[{} | {}]] | {{image uri="{}", alt="{}"}} |\n'.format( + url, + teamcity_build_name, + badge_url, + status_message, + ) + + # No build in config file, should bail out and not edit the panel with + # teamcity content + set_config_file([], []) + call_status('dont_care', BuildStatus.Success) + assert_panel_content(get_travis_panel_content()) + + # If branch is not master the panel is not updated + self.phab.set_text_panel_content.reset_mock() + call_status( + 'dont_care', + BuildStatus.Success, + branch='refs/tags/phabricator/diff/42', + expected_status_code=500 + ) + self.phab.set_text_panel_content.assert_not_called() + + # Turn travis build into failure + self.travis.get_branch_status.return_value = BuildStatus.Failure + call_status('dont_care', BuildStatus.Success) + assert_panel_content(get_travis_panel_content(BuildStatus.Failure)) + self.travis.get_branch_status.return_value = BuildStatus.Success + + # Some builds in config file but no associated teamcity build + set_config_file(["show_me11"], []) + call_status('dont_care', BuildStatus.Success) + assert_panel_content(get_travis_panel_content()) + + # Set one build to be shown and associate it. This is not the build that + # just finished. + associate_build("show_me11") + call_status('hide_me_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name') + + build_line('show_me11') + + '\n' + ) + + # Now with 3 builds from the same project + 1 not shown + set_config_file(["show_me11", "show_me12", "show_me13"], ["hidden"]) + associate_build("show_me12") + associate_build("show_me13") + call_status('hide_me_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name') + + build_line('show_me11') + + build_line('show_me12') + + build_line('show_me13') + + '\n' + ) + + # Add 2 more builds from another project. + # Check the result is always the same after a few calls + set_config_file(["show_me11", "show_me12", "show_me13", + "show_me21", "show_me22"], []) + associate_build( + "show_me21", + teamcity_project_id="ProjectId2", + teamcity_project_name="Project Name 2") + associate_build( + "show_me22", + teamcity_project_id="ProjectId2", + teamcity_project_name="Project Name 2") + for i in range(10): + call_status('hide_me_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name') + + build_line('show_me11') + + build_line('show_me12') + + build_line('show_me13') + + '\n' + + + header('Project Name 2') + + build_line('show_me21') + + build_line('show_me22') + + '\n' + ) + + # Remove a build from teamcity, but not from the config file + del associated_builds["show_me12"] + call_status('hide_me_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name') + + build_line('show_me11') + + build_line('show_me13') + + '\n' + + + header('Project Name 2') + + build_line('show_me21') + + build_line('show_me22') + + '\n' + ) + + # Hide a build from the config file (cannot be associated anymore) + set_config_file(["show_me11", "show_me12", + "show_me21", "show_me22"], ["show_me13"]) + del associated_builds["show_me13"] + call_status('hide_me_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name') + + build_line('show_me11') + + '\n' + + + header('Project Name 2') + + build_line('show_me21') + + build_line('show_me22') + + '\n' + ) + + # Remove the last build from a project and check the project is no + # longer shown + del associated_builds["show_me11"] + call_status('hide_me_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name 2') + + build_line('show_me21') + + build_line('show_me22') + + '\n' + ) + + # Check the status of the build is not checked if it didn't finish + # through the endpoint + failing_build_type_ids = ['show_me21_Type'] + call_status('hide_me_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name 2') + + build_line('show_me21') + + build_line('show_me22') + + '\n' + ) + + # But having the build to be updated through the endpoint causes the + # status to be fetched again. Note that the result is meaningless here, + # and will be fetched from Teamcity anyway. + call_status('show_me21_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name 2') + + build_line('show_me21', status=BuildStatus.Failure) + + build_line('show_me22') + + '\n' + ) + + # Check the unknown status of a build if it never completed + associate_build( + "show_me23", + teamcity_project_id="ProjectId2", + teamcity_project_name="Project Name 2") + no_complete_build_type_ids = ['show_me23_Type'] + call_status('show_me21_Type', BuildStatus.Success) + assert_panel_content( + get_travis_panel_content() + + + header('Project Name 2') + + build_line('show_me21', status=BuildStatus.Failure) + + build_line('show_me22') + + build_line('show_me23', status=BuildStatus.Unknown) + + '\n' + ) + + def test_update_coverage_panel(self): + panel_id = 21 + + self.phab.set_text_panel_content = mock.Mock() + + self.teamcity.get_coverage_summary.return_value = "Dummy" + + def call_status(status, expected_status_code=None): + data = statusRequestData() + data.buildResult = status.value + + response = self.app.post( + '/status', headers=self.headers, json=data) + assert response.status_code == ( + 200 if not expected_status_code else expected_status_code) + + def assert_panel_content(content): + self.phab.set_text_panel_content.assert_called_with( + panel_id, + content + ) + + def _assert_not_called_with(self, *args, **kwargs): + try: + self.assert_called_with(*args, **kwargs) + except AssertionError: + return + raise AssertionError( + 'Expected {} to not have been called.'.format( + self._format_mock_call_signature( + args, kwargs))) + mock.Mock.assert_not_called_with = _assert_not_called_with + + # Build failed: ignore + call_status(BuildStatus.Failure, expected_status_code=500) + self.phab.set_text_panel_content.assert_not_called_with( + panel_id=panel_id) + + # No coverage report artifact: ignore + self.teamcity.get_coverage_summary.return_value = None + call_status(BuildStatus.Success, expected_status_code=500) + self.phab.set_text_panel_content.assert_not_called_with( + panel_id=panel_id) + + self.teamcity.get_coverage_summary.return_value = \ + """ +Reading tracefile check-extended_combined.info +Summary coverage rate: + lines......: 82.3% (91410 of 111040 lines) + functions..: 74.1% (6686 of 9020 functions) + branches...: 45.0% (188886 of 420030 branches) +""" + + call_status(BuildStatus.Success, expected_status_code=500) + assert_panel_content( + """**[[ https://build.bitcoinabc.org/viewLog.html?buildId=lastSuccessful&buildTypeId=BitcoinABC_Master_BitcoinAbcMasterCoverage&tab=report__Root_Code_Coverage&guest=1 | HTML coverage report ]]** + +| Granularity | % hit | # hit | # total | +| ----------- | ----- | ----- | ------- | +| Lines | 82.3% | 91410 | 111040 | +| Functions | 74.1% | 6686 | 9020 | +| Branches | 45.0% | 188886 | 420030 | +""" + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_endpoint_triggerCI.py b/contrib/buildbot/test/test_endpoint_triggerCI.py new file mode 100755 index 000000000..5a2e480ba --- /dev/null +++ b/contrib/buildbot/test/test_endpoint_triggerCI.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2017-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import mock +import unittest +from unittest.mock import call + +from phabricator_wrapper import BITCOIN_ABC_PROJECT_PHID +from test.abcbot_fixture import ABCBotFixture +import test.mocks.phabricator +import test.mocks.teamcity + + +class EndpointTriggerCITestCase(ABCBotFixture): + def setUp(self): + super().setUp() + self.teamcity.trigger_build = mock.Mock() + + # Sane default for some properties shared between tests + self.revision_PHID = "PHID-DREV-abcdef" + self.diff_id = 1234 + self.transaction_PHID = "PHID-XACT-DREV-123456" + self.user_PHID = "PHID-USER-foobar" + self.phab.phid = self.user_PHID + + # The current user is an ABC member + self.phab.project.search.return_value = test.mocks.phabricator.Result([{ + "id": 1, + "type": "PROJ", + "phid": BITCOIN_ABC_PROJECT_PHID, + "attachments": { + "members": { + "members": [ + { + "phid": self.user_PHID + }, + ] + } + } + }]) + + # Phabricator returns the default diff ID + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([{ + "id": self.diff_id, + }]) + + # Transaction webhook on diff update + def call_endpoint(self): + webhook_transaction = { + "object": { + "phid": self.revision_PHID, + "type": "DREV", + }, + "transactions": [ + { + "phid": self.transaction_PHID + } + ] + } + + response = self.post_json_with_hmac( + '/triggerCI', + self.headers, + webhook_transaction + ) + + self.phab.transaction.search.assert_called_with( + objectIdentifier=self.revision_PHID, + constraints={ + "phids": [self.transaction_PHID], + } + ) + + return response + + def set_transaction_return_value(self, comments, user_PHID=None): + if user_PHID is None: + user_PHID = self.user_PHID + + comments_data = [ + { + "id": i, + "phid": "PHID-XCMT-comment{}".format(i), + "version": 1, + "authorPHID": user_PHID, + "dateCreated": i, + "dateModified": i, + "removed": False, + "content": { + "raw": comment + } + } + for i, comment in enumerate(comments) + ] + self.phab.transaction.search.return_value = test.mocks.phabricator.Result([{ + "id": 42, + "phid": self.transaction_PHID, + "type": "comment", + "authorPHID": "PHID-USER-foobar", + "objectPHID": self.revision_PHID, + "dateCreated": 0, + "dateModified": 0, + "groupID": "abcdef", + "comments": comments_data, + "fields": {} + }]) + + def test_triggerCI_invalid_json(self): + # Not a json content + response = self.post_data_with_hmac( + '/triggerCI', + self.headers, + "not: a valid json" + ) + self.assertEqual(response.status_code, 415) + + # Missing object + response = self.post_json_with_hmac( + '/triggerCI', + self.headers, + { + "transactions": [{ + "phid": self.revision_PHID, + }] + } + ) + self.assertEqual(response.status_code, 400) + + # Missing transaction + response = self.post_json_with_hmac( + '/triggerCI', + self.headers, + {"object": "dummy"} + ) + self.assertEqual(response.status_code, 400) + + # Missing object type + response = self.post_json_with_hmac( + '/triggerCI', + self.headers, + { + "object": { + "phid": self.revision_PHID, + }, + "transactions": [{ + "phid": self.revision_PHID, + }], + } + ) + self.assertEqual(response.status_code, 400) + + # Missing object phid + response = self.post_json_with_hmac( + '/triggerCI', + self.headers, + { + "object": { + "type": "DREV", + }, + "transactions": [{ + "phid": self.revision_PHID, + }] + } + ) + self.assertEqual(response.status_code, 400) + + # Wrong object type + response = self.post_json_with_hmac( + '/triggerCI', + self.headers, + { + "object": { + "phid": "PHID-TASK-123456", + "type": "TASK", + }, + "transactions": [{ + "phid": self.revision_PHID, + }] + } + ) + self.assertEqual(response.status_code, 200) + + # Empty transactions + response = self.post_json_with_hmac( + '/triggerCI', + self.headers, + { + "object": { + "phid": "PHID-TASK-123456", + "type": "TASK", + }, + "transactions": [], + } + ) + self.assertEqual(response.status_code, 200) + + def test_triggerCI_no_build_queued(self): + # No comment to parse + response = self.call_endpoint() + self.assertEqual(response.status_code, 200) + + # No build triggered, exit status OK, independent of the user + def test_no_build_user_independent(comments): + users = [self.user_PHID, "PHID-USER-nonabc"] + for user in users: + self.set_transaction_return_value(comments, user) + response = self.call_endpoint() + self.teamcity.trigger_build.assert_not_called() + self.assertEqual(response.status_code, 200) + + # Any user, 1 comment not targeting the bot + test_no_build_user_independent([ + "This is a benign comment", + ]) + + # Any user, 3 comments not targeting the bot + test_no_build_user_independent([ + "Useless comment 1", + "Useless @bot comment 2", + "Useless comment @bot 3", + ]) + + # Any user, 1 comment targeting the bot but no build + test_no_build_user_independent([ + "@bot", + ]) + + # Unauthorized user, 1 comment targeting the bot with 1 build + self.set_transaction_return_value( + [ + "@bot build-1", + ], + "PHID-USER-nonabc" + ) + response = self.call_endpoint() + self.teamcity.session.send.assert_not_called() + self.assertEqual(response.status_code, 200) + + # Unauthorized user, 3 comments targeting the bot with 3 builds + self.set_transaction_return_value( + [ + "@bot build-11 build-12 build-13", + "@bot build-21 build-22 build-23", + "@bot build-31 build-32 build-33", + ], + "PHID-USER-nonabc" + ) + response = self.call_endpoint() + self.teamcity.session.send.assert_not_called() + self.assertEqual(response.status_code, 200) + + def test_triggerCI_some_build_queued(self): + def assert_teamcity_queued_builds(comments, queued_builds): + self.set_transaction_return_value(comments) + response = self.call_endpoint() + expected_calls = [ + call( + "BitcoinABC_BitcoinAbcStaging", + "refs/tags/phabricator/diff/{}".format(self.diff_id), + properties=[{ + 'name': 'env.ABC_BUILD_NAME', + 'value': build_id, + }] + ) + for build_id in queued_builds + ] + print(expected_calls) + self.teamcity.trigger_build.assert_has_calls( + expected_calls, any_order=True) + self.assertEqual(response.status_code, 200) + + # Authorized user, 1 comment targeting the bot with 1 build + assert_teamcity_queued_builds( + [ + "@bot build-1", + ], + [ + "build-1", + ] + ) + + # Authorized user, 1 comment targeting the bot with 3 builds + assert_teamcity_queued_builds( + [ + "@bot build-1 build-2 build-3", + ], + [ + "build-1", + "build-2", + "build-3", + ] + ) + + # Authorized user, 3 comments targeting the bot with 3 builds each + assert_teamcity_queued_builds( + [ + "@bot build-11 build-12 build-13", + "@bot build-21 build-22 build-23", + "@bot build-31 build-32 build-33", + ], + [ + "build-11", "build-12", "build-13", + "build-21", "build-22", "build-23", + "build-31", "build-32", "build-33", + ] + ) + + # Authorized user, 1 comment targeting the bot with duplicated builds + assert_teamcity_queued_builds( + [ + "@bot build-1 build-2 build-1 build-3 build-2", + ], + [ + "build-1", + "build-2", + "build-3", + ] + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_phabricator.py b/contrib/buildbot/test/test_phabricator.py new file mode 100755 index 000000000..ec0a3f04b --- /dev/null +++ b/contrib/buildbot/test/test_phabricator.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from base64 import b64encode +import mock +import os +import unittest + +from phabricator import Result +from phabricator_wrapper import BITCOIN_ABC_PROJECT_PHID, BITCOIN_ABC_REPO +import test.mocks.phabricator + + +class PhabricatorTests(unittest.TestCase): + def setUp(self): + self.phab = test.mocks.phabricator.instance() + + def tearDown(self): + pass + + def test_get_project_members(self): + self.phab.project.search.return_value = test.mocks.phabricator.Result([ + { + "id": 1, + "type": "PROJ", + "phid": BITCOIN_ABC_PROJECT_PHID, + "attachments": { + "members": { + "members": [ + { + "phid": "PHID-USER-usernumber1" + }, + { + "phid": "PHID-USER-usernumber2" + }, + { + "phid": "PHID-USER-usernumber3" + }, + ] + } + } + } + ]) + + abc_members = self.phab.get_project_members(BITCOIN_ABC_PROJECT_PHID) + self.phab.project.search.assert_called_with( + constraints={ + "phids": [BITCOIN_ABC_PROJECT_PHID], + }, + attachments={ + "members": True, + }, + ) + self.assertEqual( + abc_members, + [ + "PHID-USER-usernumber1", + "PHID-USER-usernumber2", + "PHID-USER-usernumber3", + ] + ) + + def test_get_latest_diff_staging_ref(self): + revision_PHID = "PHID-DREV-987645" + + def assert_diff_searched_called(): + return self.phab.differential.diff.search.assert_called_with( + constraints={ + "revisionPHIDs": [revision_PHID], + }, + order="newest" + ) + + # No diff associated to the revision + ref = self.phab.get_latest_diff_staging_ref(revision_PHID) + assert_diff_searched_called() + self.assertEqual(ref, "") + + # 2 diffs associated with the revision. Ordering is guaranteed by the + # "order" request parameter. + self.phab.differential.diff.search.return_value = test.mocks.phabricator.Result([ + { + "id": 42, + "type": "DIFF", + "phid": "PHID-DIFF-123456", + }, + { + "id": 41, + "type": "DIFF", + "phid": "PHID-DIFF-abcdef", + }, + ]) + + ref = self.phab.get_latest_diff_staging_ref(revision_PHID) + assert_diff_searched_called() + self.assertEqual(ref, "refs/tags/phabricator/diff/42") + + def test_get_current_user_phid(self): + user_PHID = "PHID-USER-foobarbaz" + + self.phab.user.whoami.return_value = { + "phid": user_PHID, + "userName": "foo", + "realName": "Foo Bar", + } + + # The whoami result should be cached. Call the method a few times and + # check the call occurs once and the result is always as expected. + for i in range(10): + phid = self.phab.get_current_user_phid() + self.phab.user.whoami.assert_called_once() + self.assertEqual(phid, user_PHID) + + def test_getRevisionAuthor(self): + self.phab.differential.revision.search.return_value = test.mocks.phabricator.Result([{ + 'fields': { + 'authorPHID': 'PHID-USER-2345', + }, + }]) + expectedAuthor = { + "phid": 'PHID-USER-2345', + } + self.phab.user.search.return_value = test.mocks.phabricator.Result([ + expectedAuthor]) + actualAuthor = self.phab.getRevisionAuthor('D1234') + self.assertEqual(actualAuthor, expectedAuthor) + + def test_getAuthorSlackUsername(self): + self.assertEqual("", self.phab.getAuthorSlackUsername({})) + self.assertEqual("", self.phab.getAuthorSlackUsername({'fields': {}})) + self.assertEqual("test-slack-name", self.phab.getAuthorSlackUsername({ + 'fields': { + 'custom.abc:slack-username': 'test-slack-name', + 'username': 'test-username', + }, + })) + self.assertEqual("test-username", self.phab.getAuthorSlackUsername({ + 'fields': { + 'username': 'test-username', + }, + })) + + def test_user_roles(self): + user_PHID = "PHID-USER-abcdef" + + def assert_user_search_called(): + return self.phab.user.search.assert_called_with( + constraints={ + "phids": [user_PHID], + } + ) + + # User not found + user_roles = self.phab.get_user_roles(user_PHID) + assert_user_search_called() + self.assertEqual(user_roles, []) + + # User found + self.phab.user.search.return_value = test.mocks.phabricator.Result([ + { + "id": 1, + "type": "USER", + "phid": user_PHID, + "fields": { + "username": "foobar", + "realName": "Foo Bar", + "roles": [ + "admin", + "verified", + "approved", + "activated", + ], + "dateCreated": 0, + "dateModified": 0, + "custom.abc:slack-username": "Foobar", + }, + }, + ]) + user_roles = self.phab.get_user_roles(user_PHID) + assert_user_search_called() + self.assertEqual( + user_roles, + [ + "admin", + "verified", + "approved", + "activated", + ] + ) + + # If more than 1 user is returned (should never occur), check no role is + # returned to prevent privilege exploits. + self.phab.user.search.return_value = test.mocks.phabricator.Result([ + { + "id": 1, + "type": "USER", + "phid": user_PHID, + "fields": { + "roles": [ + "verified", + ], + }, + }, + { + "id": 2, + "type": "USER", + "phid": user_PHID, + "fields": { + "roles": [ + "admin", + ], + }, + }, + ]) + user_roles = self.phab.get_user_roles(user_PHID) + assert_user_search_called() + self.assertEqual(user_roles, []) + + def test_get_laster_master_commit_hash(self): + with self.assertRaises(AssertionError): + self.phab.get_latest_master_commit_hash() + + self.phab.diffusion.commit.search.return_value = test.mocks.phabricator.Result([ + { + "id": 1234, + "type": "CMIT", + "phid": "PHID-CMIT-abcdef", + "fields": { + "identifier": "0000000000000000000000000000000123456789", + "repositoryPHID": "PHID-REPO-abcrepo", + }, + } + ]) + + commit_hash = self.phab.get_latest_master_commit_hash() + self.phab.diffusion.commit.search.assert_called_with( + constraints={ + "repositories": [BITCOIN_ABC_REPO], + }, + limit=1, + ) + self.assertEqual( + commit_hash, + "0000000000000000000000000000000123456789") + + def test_get_file_content_from_master(self): + commit_hash = "0000000000000000000000000000000123456789" + file_phid = "PHID-FILE-somefile" + path = "some/file" + + self.phab.get_latest_master_commit_hash = mock.Mock() + self.phab.get_latest_master_commit_hash.return_value = commit_hash + + self.phab.diffusion.browsequery = mock.Mock() + + def configure_browsequery(file_path=path, hash="abcdef"): + self.phab.diffusion.browsequery.return_value = { + "paths": [ + { + "fullPath": "some/file/1", + "hash": "1234" + }, + { + "fullPath": "some/file/2", + "hash": "5678" + }, + { + "fullPath": file_path, + "hash": hash + }, + ] + } + + def assert_diffusion_browsequery_called(): + self.phab.get_latest_master_commit_hash.assert_called() + self.phab.diffusion.browsequery.assert_called_with( + path=os.path.dirname(path) or None, + commit=commit_hash, + repository=BITCOIN_ABC_REPO, + branch="master", + ) + + def configure_file_content_query( + file_phid=file_phid, too_slow=False, too_huge=False): + output = { + "tooSlow": too_slow, + "tooHuge": too_huge, + } + if file_phid is not None: + output["filePHID"] = file_phid + + self.phab.diffusion.filecontentquery.return_value = output + + def assert_file_commit_and_file_searched(): + self.phab.get_latest_master_commit_hash.assert_called() + self.phab.diffusion.filecontentquery.assert_called_with( + path=path, + commit=commit_hash, + timeout=5, + byteLimit=1024 * 1024, + repository=BITCOIN_ABC_REPO, + branch="master", + ) + + # Browse query failure + self.phab.diffusion.browsequery.return_value = {} + with self.assertRaisesRegex(AssertionError, "File .+ not found in master"): + self.phab.get_file_content_from_master(path) + assert_diffusion_browsequery_called() + + # Browse query returns no file + self.phab.diffusion.browsequery.return_value = {'paths': []} + with self.assertRaisesRegex(AssertionError, "File .+ not found in master"): + self.phab.get_file_content_from_master(path) + assert_diffusion_browsequery_called() + + # Browse query failed to find our file + configure_browsequery(file_path='something/else') + with self.assertRaisesRegex(AssertionError, "File .+ not found in master"): + self.phab.get_file_content_from_master(path) + assert_diffusion_browsequery_called() + + configure_browsequery() + + # Missing file PHID + configure_file_content_query(file_phid=None) + with self.assertRaisesRegex(AssertionError, "File .+ not found in master"): + self.phab.get_file_content_from_master(path) + assert_file_commit_and_file_searched() + + # Too long + configure_file_content_query(too_slow=True) + with self.assertRaisesRegex(AssertionError, "is oversized or took too long to download"): + self.phab.get_file_content_from_master(path) + assert_file_commit_and_file_searched() + + # Too huge + configure_file_content_query(too_huge=True) + with self.assertRaisesRegex(AssertionError, "is oversized or took too long to download"): + self.phab.get_file_content_from_master(path) + assert_file_commit_and_file_searched() + + # Check the file content can be retrieved + expected_content = b'Some nice content' + self.phab.file.download.return_value = Result( + b64encode(expected_content)) + configure_file_content_query() + file_content = self.phab.get_file_content_from_master(path) + assert_file_commit_and_file_searched() + self.phab.file.download.assert_called_with(phid=file_phid) + self.assertEqual(file_content, expected_content) + + # With later calls the content is returned directly thanks to the cache + self.phab.diffusion.filecontentquery.reset_mock() + self.phab.file.download.reset_mock() + for i in range(10): + file_content = self.phab.get_file_content_from_master(path) + self.assertEqual(file_content, expected_content) + self.phab.diffusion.filecontentquery.assert_not_called() + self.phab.file.download.assert_not_called() + + # If the master commit changes, the file content is still valid in cache + # as long as its file hash is unchanged + for i in range(10): + commit_hash = str(int(commit_hash) + 1) + self.phab.get_latest_master_commit_hash.return_value = commit_hash + + file_content = self.phab.get_file_content_from_master(path) + self.assertEqual(file_content, expected_content) + self.phab.diffusion.filecontentquery.assert_not_called() + self.phab.file.download.assert_not_called() + + # But if the file hash changes, the file content needs to be updated... + configure_browsequery(hash="defghi") + file_content = self.phab.get_file_content_from_master(path) + assert_file_commit_and_file_searched() + self.phab.file.download.assert_called_with(phid=file_phid) + self.assertEqual(file_content, expected_content) + + # ... only once. + self.phab.diffusion.filecontentquery.reset_mock() + self.phab.file.download.reset_mock() + for i in range(10): + file_content = self.phab.get_file_content_from_master(path) + self.assertEqual(file_content, expected_content) + self.phab.diffusion.filecontentquery.assert_not_called() + self.phab.file.download.assert_not_called() + + def test_set_text_panel_content(self): + panel_id = 42 + panel_content = "My wonderful panel content" + + self.phab.dashboard.panel.edit.return_value = { + "error": None, + "errorMessage": None, + "response": { + "object": { + "id": panel_id, + "phid": "PHID-DSHP-123456789", + "transactions": [ + { + "phid": "PHID-XACT-DSHP-abcdefghi" + } + ] + } + } + } + + def call_set_text_panel_content(): + self.phab.set_text_panel_content(panel_id, panel_content) + self.phab.dashboard.panel.edit.assert_called_with( + objectIdentifier=panel_id, + transactions=[ + { + "type": "text", + "value": panel_content + } + ] + ) + + # Happy path + call_set_text_panel_content() + + # Error + self.phab.dashboard.panel.edit.return_value["error"] = "You shall not pass !" + with self.assertRaisesRegex(AssertionError, "Failed to edit panel"): + call_set_text_panel_content() + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_slackbot.py b/contrib/buildbot/test/test_slackbot.py new file mode 100755 index 000000000..02fa590e9 --- /dev/null +++ b/contrib/buildbot/test/test_slackbot.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import mock +import unittest + +from slackbot import SlackBot + +import test.mocks.slackbot + + +def mockSlackBot(): + channels = { + 'test': '#test-channel', + } + slackbot = SlackBot(mock.Mock, 'slack-token', channels) + return slackbot + + +class SlackbotTestCase(unittest.TestCase): + def setUp(self): + self.slackbot = mockSlackBot() + + def tearDown(self): + pass + + def test_postMessage(self): + message = "test message" + expectedAssertionMessage = "Invalid channel: Channel must be a user ID or configured with a channel name" + self.assertRaisesRegex( + AssertionError, + expectedAssertionMessage, + self.slackbot.postMessage, + None, + message) + self.assertRaisesRegex( + AssertionError, + expectedAssertionMessage, + self.slackbot.postMessage, + 'doesnt-exist', + message) + + self.slackbot.postMessage('U1234', message) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='U1234', text=message) + + self.slackbot.postMessage('test', message) + self.slackbot.client.chat_postMessage.assert_called_with( + channel='#test-channel', text=message) + + def test_getUserByName(self): + user = test.mocks.slackbot.user() + self.slackbot.client.users_list.return_value = test.mocks.slackbot.users_list( + initialUsers=[user]) + assert self.slackbot.getUserByName('Other Name') is None + assert self.slackbot.getUserByName('Real Name') == user + assert self.slackbot.getUserByName('Real Name Normalized') == user + assert self.slackbot.getUserByName('Display Name') == user + assert self.slackbot.getUserByName('Display Name Normalized') == user + + def test_formatMentionByName(self): + user = test.mocks.slackbot.user() + expectedMention = '<@{}>'.format(user['id']) + self.slackbot.client.users_list.return_value = test.mocks.slackbot.users_list( + initialUsers=[user]) + assert self.slackbot.formatMentionByName('testname') is None + assert self.slackbot.formatMentionByName( + 'Real Name') == expectedMention + assert self.slackbot.formatMentionByName( + 'Real Name Normalized') == expectedMention + assert self.slackbot.formatMentionByName( + 'Display Name') == expectedMention + assert self.slackbot.formatMentionByName( + 'Display Name Normalized') == expectedMention + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_teamcity.py b/contrib/buildbot/test/test_teamcity.py new file mode 100755 index 000000000..5332f1213 --- /dev/null +++ b/contrib/buildbot/test/test_teamcity.py @@ -0,0 +1,636 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import json +import mock +import requests +import time +import unittest +from urllib.parse import urljoin + +from teamcity import TeamcityRequestException +from testutil import AnyWith + +import test.mocks.teamcity + + +class TeamcityTests(unittest.TestCase): + def setUp(self): + self.teamcity = test.mocks.teamcity.instance() + + def tearDown(self): + pass + + def test_mockTime(self): + currentTime = int(time.time()) - 1 + assert self.teamcity.getTime() >= currentTime + + self.teamcity.setMockTime(1593635000) + assert self.teamcity.getTime() == 1593635000 + + def test_build_url(self): + assert self.teamcity.build_url() == urljoin(self.teamcity.base_url, "?guest=1") + assert self.teamcity.build_url("foo.html") == urljoin( + self.teamcity.base_url, "foo.html?guest=1") + assert self.teamcity.build_url( + "foo.html", + { + "foo": "bar", + "bar": "baz", + }) == urljoin(self.teamcity.base_url, "foo.html?foo=bar&bar=baz&guest=1") + assert self.teamcity.build_url( + "foo.html", + { + "foo": "bar", + "baz": 42, + }) == urljoin(self.teamcity.base_url, "foo.html?foo=bar&baz=42&guest=1") + assert self.teamcity.build_url( + "foo.html", + { + "foo": "bar", + "baz": 42 + }, + "anchor") == urljoin(self.teamcity.base_url, "foo.html?foo=bar&baz=42&guest=1#anchor") + # No path, a fragment but no query + assert self.teamcity.build_url( + fragment="anchor") == urljoin(self.teamcity.base_url, "?guest=1#anchor") + # Some path, a fragment but no query + assert self.teamcity.build_url( + "foo.html", + fragment="anchor") == urljoin(self.teamcity.base_url, "foo.html?guest=1#anchor") + # Use RFC 3986 compliant chars + assert self.teamcity.build_url( + "foo.html", + { + "valid": "build($changes(*),properties(?),'triggered([a]:!b&c)')" + }) == urljoin(self.teamcity.base_url, "foo.html?valid=build%28%24changes%28%2A%29%2Cproperties%28%3F%29%2C%27triggered%28%5Ba%5D%3A%21b%26c%29%27%29&guest=1") + # Check other chars are also quoted/unquoted correctly + assert self.teamcity.build_url( + "foo.html", + { + "invalid": "space space,slash/slash,doublequote\"doublequote" + }) == urljoin(self.teamcity.base_url, "foo.html?invalid=space+space%2Cslash%2Fslash%2Cdoublequote%22doublequote&guest=1") + # The guest is already set to any value + assert self.teamcity.build_url( + "foo.html", + { + "foo": "bar", + "guest": 0, + }) == urljoin(self.teamcity.base_url, "foo.html?foo=bar&guest=0") + assert self.teamcity.build_url( + "foo.html", + { + "foo": "bar", + "guest": 1, + }) == urljoin(self.teamcity.base_url, "foo.html?foo=bar&guest=1") + # No guest=1 parameter is appended when calling the rest API + assert self.teamcity.build_url( + "app/rest/foo", + { + "foo": "bar", + }) == urljoin(self.teamcity.base_url, "app/rest/foo?foo=bar") + + def test_convert_to_guest_url(self): + expect_no_update = [ + # Not a valid teamcity URL + "", + "http://foo.bar", + + # Already a guest + urljoin(self.teamcity.base_url, "?guest=1"), + urljoin(self.teamcity.base_url, "?foo=bar&guest=1"), + urljoin(self.teamcity.base_url, "?foo=bar&guest=1#anchor"), + ] + + expect_update = [ + ( + self.teamcity.base_url, + urljoin(self.teamcity.base_url, "?guest=1") + ), + ( + urljoin(self.teamcity.base_url, "?"), + urljoin(self.teamcity.base_url, "?guest=1") + ), + ( + urljoin(self.teamcity.base_url, "?foo=bar"), + urljoin(self.teamcity.base_url, "?foo=bar&guest=1") + ), + ( + urljoin(self.teamcity.base_url, "?foo=bar&bar=baz"), + urljoin(self.teamcity.base_url, "?foo=bar&bar=baz&guest=1") + ), + ( + urljoin(self.teamcity.base_url, "#anchor"), + urljoin(self.teamcity.base_url, "?guest=1#anchor") + ), + ( + urljoin(self.teamcity.base_url, "?foo=bar#anchor"), + urljoin(self.teamcity.base_url, "?foo=bar&guest=1#anchor") + ), + ( + urljoin(self.teamcity.base_url, "?foo=bar&bar=baz#anchor"), + urljoin( + self.teamcity.base_url, + "?foo=bar&bar=baz&guest=1#anchor") + ), + ] + + for url in expect_no_update: + assert self.teamcity.convert_to_guest_url(url) == url + + for url_in, url_out in expect_update: + assert self.teamcity.convert_to_guest_url(url_in) == url_out + + def test_requestFailure(self): + self.teamcity.session.send.return_value.status_code = requests.codes.bad_request + req = self.teamcity._request('GET', 'https://endpoint') + self.assertRaises( + TeamcityRequestException, + self.teamcity.getResponse, + req) + + def test_getBuildProblems_noProblems(self): + self.teamcity.session.send.return_value.content = json.dumps({}) + output = self.teamcity.getBuildProblems('1234') + assert output == [] + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/problemOccurrences", + { + "locator": "build:(id:1234)", + "fields": "problemOccurrence(id,details)", + } + ) + })) + + def test_getBuildProblems_hasProblems(self): + problems = [{ + 'id': 'id:2500,build:(id:12345)', + 'details': 'test-details', + }] + self.teamcity.session.send.return_value.content = json.dumps({ + 'problemOccurrence': problems, + }) + output = self.teamcity.getBuildProblems('1234') + assert output[0]['id'] == problems[0]['id'] + assert output[0]['details'] == problems[0]['details'] + assert output[0]['logUrl'] == self.teamcity.build_url( + "viewLog.html", + { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": 1234, + }, + "footer" + ) + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/problemOccurrences", + { + "locator": "build:(id:1234)", + "fields": "problemOccurrence(id,details)", + } + ) + })) + + def test_getFailedTests_noTestFailures(self): + self.teamcity.session.send.return_value.content = json.dumps({}) + output = self.teamcity.getFailedTests('1234') + assert output == [] + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/testOccurrences", + { + "locator": "build:(id:1234),status:FAILURE", + "fields": "testOccurrence(id,details,name)", + } + ) + })) + + def test_getFailedTests_hasTestFailures(self): + failures = [{ + 'id': 'id:2500,build:(id:12345)', + 'details': 'stacktrace', + 'name': 'test name', + }] + self.teamcity.session.send.return_value.content = json.dumps({ + 'testOccurrence': failures, + }) + output = self.teamcity.getFailedTests('1234') + assert output[0]['id'] == failures[0]['id'] + assert output[0]['details'] == failures[0]['details'] + assert output[0]['name'] == failures[0]['name'] + assert output[0]['logUrl'] == self.teamcity.build_url( + "viewLog.html", + { + "tab": "buildLog", + "logTab": "tree", + "filter": "debug", + "expand": "all", + "buildId": 1234, + "_focus": 2500, + } + ) + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/testOccurrences", + { + "locator": "build:(id:1234),status:FAILURE", + "fields": "testOccurrence(id,details,name)", + } + ) + })) + + def test_triggerBuild(self): + triggerBuildResponse = test.mocks.teamcity.buildInfo( + test.mocks.teamcity.buildInfo_changes(['test-change'])) + self.teamcity.session.send.return_value = triggerBuildResponse + output = self.teamcity.trigger_build('1234', 'branch-name', 'test-phid', [{ + 'name': 'another-property', + 'value': 'some value', + }]) + assert output == json.loads(triggerBuildResponse.content) + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url("app/rest/buildQueue"), + 'body': json.dumps({ + 'branchName': 'branch-name', + 'buildType': { + 'id': '1234', + }, + 'properties': { + 'property': [{ + 'name': 'another-property', + 'value': 'some value', + }, { + 'name': 'env.harborMasterTargetPHID', + 'value': 'test-phid', + }], + }, + }), + })) + + def test_getBuildChangeDetails(self): + expectedOutput = { + 'username': 'email@bitcoinabc.org', + 'user': { + 'name': 'Author Name', + }, + } + self.teamcity.session.send.return_value.content = json.dumps( + expectedOutput) + output = self.teamcity.getBuildChangeDetails('1234') + assert output == expectedOutput + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url("app/rest/changes/1234") + })) + + def test_getBuildChanges(self): + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(json.dumps({ + 'change': [{ + 'id': '1234', + }], + })), + test.mocks.teamcity.Response(json.dumps({ + 'username': 'email@bitcoinabc.org', + 'user': { + 'name': 'Author Name', + }, + })), + ] + output = self.teamcity.getBuildChanges('2345') + assert output[0]['username'] == 'email@bitcoinabc.org' + assert output[0]['user']['name'] == 'Author Name' + calls = [mock.call(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/changes", + { + "locator": "build:(id:2345)", + "fields": "change(id)", + } + ) + })), mock.call(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url("app/rest/changes/1234") + }))] + self.teamcity.session.send.assert_has_calls(calls, any_order=False) + + def test_getBuildInfo(self): + self.teamcity.session.send.return_value = test.mocks.teamcity.buildInfo( + properties=test.mocks.teamcity.buildInfo_properties([{ + 'name': 'env.ABC_BUILD_NAME', + 'value': 'build-diff', + }]), + changes=test.mocks.teamcity.buildInfo_changes( + ['101298f9325ddbac7e5a8f405e5e2f24a64e5171']), + ) + buildInfo = self.teamcity.getBuildInfo('1234') + assert buildInfo['triggered']['type'] == 'vcs' + assert buildInfo.getProperties().get('env.ABC_BUILD_NAME') == 'build-diff' + assert buildInfo.getCommits( + )[0] == '101298f9325ddbac7e5a8f405e5e2f24a64e5171' + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/builds", + { + "locator": "id:1234", + "fields": "build(*,changes(*),properties(*),triggered(*))", + } + ) + })) + + def test_getBuildInfo_noInfo(self): + self.teamcity.session.send.return_value = test.mocks.teamcity.Response( + json.dumps({})) + buildInfo = self.teamcity.getBuildInfo('1234') + assert buildInfo.get('triggered', None) is None + assert buildInfo.getProperties() is None + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/builds", + { + "locator": "id:1234", + "fields": "build(*,changes(*),properties(*),triggered(*))", + } + ) + })) + + def test_buildTriggeredByAutomatedUser(self): + self.teamcity.session.send.return_value = test.mocks.teamcity.buildInfo_automatedBuild() + buildInfo = self.teamcity.getBuildInfo('1234') + self.assertTrue(self.teamcity.checkBuildIsAutomated(buildInfo)) + self.assertFalse(self.teamcity.checkBuildIsScheduled(buildInfo)) + + def test_buildTriggeredManually(self): + self.teamcity.session.send.return_value = test.mocks.teamcity.buildInfo_userBuild() + buildInfo = self.teamcity.getBuildInfo('1234') + self.assertFalse(self.teamcity.checkBuildIsAutomated(buildInfo)) + self.assertFalse(self.teamcity.checkBuildIsScheduled(buildInfo)) + + def test_buildTriggeredBySchedule(self): + self.teamcity.session.send.return_value = test.mocks.teamcity.buildInfo_scheduledBuild() + buildInfo = self.teamcity.getBuildInfo('1234') + self.assertTrue(self.teamcity.checkBuildIsAutomated(buildInfo)) + self.assertTrue(self.teamcity.checkBuildIsScheduled(buildInfo)) + + def test_buildTriggeredByVcsCheckin(self): + self.teamcity.session.send.return_value = test.mocks.teamcity.buildInfo_vcsCheckinBuild() + buildInfo = self.teamcity.getBuildInfo('1234') + self.assertTrue(self.teamcity.checkBuildIsAutomated(buildInfo)) + self.assertFalse(self.teamcity.checkBuildIsScheduled(buildInfo)) + + def test_getLatestBuildAndTestFailures(self): + self.teamcity.session.send.side_effect = [ + test.mocks.teamcity.Response(json.dumps({ + 'problemOccurrence': [{ + 'id': 'id:2500,build:(id:1000)', + 'details': 'build-details', + 'build': { + 'buildTypeId': 'build1', + }, + }, { + 'id': 'id:2501,build:(id:1001)', + 'details': 'build-details', + 'build': { + 'buildTypeId': 'build2', + }, + }], + })), + test.mocks.teamcity.Response(json.dumps({ + 'testOccurrence': [{ + 'id': 'id:2501,build:(id:1001)', + 'details': 'test-details', + 'build': { + 'buildTypeId': 'build2', + }, + }, { + 'id': 'id:2502,build:(id:1002)', + 'details': 'test-details', + 'build': { + 'buildTypeId': 'build3', + }, + }], + })), + ] + + (buildFailures, testFailures) = self.teamcity.getLatestBuildAndTestFailures( + 'BitcoinABC_Master') + assert len(buildFailures) == 2 + assert len(testFailures) == 2 + + teamcityCalls = [mock.call(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/problemOccurrences", + { + "locator": "currentlyFailing:true,affectedProject:(id:BitcoinABC_Master)", + "fields": "problemOccurrence(*)", + } + ) + })), mock.call(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/testOccurrences", + { + "locator": "currentlyFailing:true,affectedProject:(id:BitcoinABC_Master)", + "fields": "testOccurrence(*)", + } + ) + }))] + self.teamcity.session.send.assert_has_calls( + teamcityCalls, any_order=False) + + def test_getLatestCompletedBuild(self): + def call_getLastCompletedBuild(): + output = self.teamcity.getLatestCompletedBuild('1234') + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/builds", + { + "locator": "buildType:1234", + "fields": "build(id)", + "count": 1, + } + ) + })) + return output + + # No build completed yet + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [], + }) + self.assertEqual(call_getLastCompletedBuild(), None) + + # A build completed + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [{ + 'id': 1234, + }], + }) + build = call_getLastCompletedBuild() + self.assertEqual(build["id"], 1234) + + def test_formatTime(self): + assert self.teamcity.formatTime(1590000000) == '20200520T184000+0000' + + def test_getNumAggregateFailuresSince(self): + self.teamcity.setMockTime(1590000000) + + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [], + }) + assert self.teamcity.getNumAggregateFailuresSince('buildType', 0) == 0 + + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [ + {'status': 'SUCCESS'}, + {'status': 'SUCCESS'}, + {'status': 'SUCCESS'}, + ], + }) + assert self.teamcity.getNumAggregateFailuresSince('buildType', 0) == 0 + + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [{'status': 'FAILURE'}], + }) + assert self.teamcity.getNumAggregateFailuresSince('buildType', 0) == 1 + + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [ + {'status': 'FAILURE'}, + {'status': 'FAILURE'}, + {'status': 'FAILURE'}, + ] + }) + assert self.teamcity.getNumAggregateFailuresSince('buildType', 0) == 1 + + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [ + {'status': 'FAILURE'}, + {'status': 'FAILURE'}, + {'status': 'SUCCESS'}, + {'status': 'FAILURE'}, + ] + }) + assert self.teamcity.getNumAggregateFailuresSince('buildType', 0) == 2 + + self.teamcity.session.send.return_value.content = json.dumps({ + 'build': [ + {'status': 'SUCCESS'}, + {'status': 'FAILURE'}, + {'status': 'FAILURE'}, + {'status': 'SUCCESS'}, + {'status': 'FAILURE'}, + {'status': 'FAILURE'}, + {'status': 'FAILURE'}, + {'status': 'SUCCESS'}, + {'status': 'FAILURE'}, + {'status': 'SUCCESS'}, + ] + }) + assert self.teamcity.getNumAggregateFailuresSince( + 'buildType', 10000000) == 3 + + self.teamcity.session.send.assert_called_with(AnyWith(requests.PreparedRequest, { + 'url': self.teamcity.build_url( + "app/rest/builds", + { + "locator": "buildType:{},sinceDate:{}".format('buildType', + self.teamcity.formatTime(1580000000)), + "fields": "build", + } + ) + })) + + def test_associate_configuration_names(self): + project_id = "Project" + + def configure_build_types(start=0, stop=10, project=project_id): + self.teamcity.session.send.return_value.content = json.dumps({ + "buildType": [ + { + "id": "{}_Build{}".format(project, i), + "name": "My build {}".format(i), + "project": { + "id": "Root_{}".format(project), + "name": "My project {}".format(project) + }, + "parameters": { + "property": [ + { + "name": "env.ABC_BUILD_NAME", + "value": "build-{}".format(i) + } + ] + } + } + for i in range(start, stop) + ] + }) + + def call_associate_configuration_names( + build_names, project=project_id): + config = self.teamcity.associate_configuration_names( + project, build_names) + self.teamcity.session.send.assert_called() + return config + + build_names = ["build-{}".format(i) for i in range(3)] + + # No build type configured + configure_build_types(0, 0) + config = call_associate_configuration_names(build_names) + self.assertDictEqual(config, {}) + + # No matching build configuration + configure_build_types(4, 10) + config = call_associate_configuration_names(build_names) + self.assertDictEqual(config, {}) + + # Partial match + configure_build_types(2, 10) + config = call_associate_configuration_names(build_names) + self.assertDictEqual(config, + { + "build-2": { + "teamcity_build_type_id": "Project_Build2", + "teamcity_build_name": "My build 2", + "teamcity_project_id": "Root_Project", + "teamcity_project_name": "My project Project", + }, + } + ) + + # Full match, change project name + project_id = "OtherProject" + configure_build_types(0, 10, project=project_id) + config = call_associate_configuration_names( + build_names, project=project_id) + self.assertDictEqual(config, + { + "build-0": { + "teamcity_build_type_id": "OtherProject_Build0", + "teamcity_build_name": "My build 0", + "teamcity_project_id": "Root_OtherProject", + "teamcity_project_name": "My project OtherProject", + }, + "build-1": { + "teamcity_build_type_id": "OtherProject_Build1", + "teamcity_build_name": "My build 1", + "teamcity_project_id": "Root_OtherProject", + "teamcity_project_name": "My project OtherProject", + }, + "build-2": { + "teamcity_build_type_id": "OtherProject_Build2", + "teamcity_build_name": "My build 2", + "teamcity_project_id": "Root_OtherProject", + "teamcity_project_name": "My project OtherProject", + }, + } + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_testutil.py b/contrib/buildbot/test/test_testutil.py new file mode 100755 index 000000000..cc62f1e71 --- /dev/null +++ b/contrib/buildbot/test/test_testutil.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import unittest + +from testutil import AnyWith + + +class TestObject(): + mystr = 'value' + mydict = { + 'item': 'value', + } + + +def TestAnyWith(expected): + aw = AnyWith(TestObject, expected) + return aw.__eq__(TestObject()) + + +class TestUtilTests(unittest.TestCase): + def test_compareWrongType(self): + # dict is not a TestObject + self.assertRaisesRegex( + AssertionError, + "Argument class type did not match", + AnyWith( + TestObject, + None).__eq__, + {}) + + def test_happyPaths(self): + self.assertRaisesRegex( + AssertionError, "Argument missing expected attribute", TestAnyWith, { + 'does-not-exist': None}) + self.assertRaisesRegex( + AssertionError, "Argument missing expected attribute", TestAnyWith, { + 'does-not-exist': 'value'}) + self.assertRaisesRegex(AssertionError, + "Argument missing expected attribute", + TestAnyWith, + {'does-not-exist': {'item': 'value'}}) + + TestAnyWith({'mystr': 'value'}) + self.assertRaisesRegex( + AssertionError, "Argument attribute type did not match", TestAnyWith, { + 'mystr': None}) + self.assertRaisesRegex( + AssertionError, "Argument attribute type did not match", TestAnyWith, { + 'mystr': {}}) + self.assertRaisesRegex( + AssertionError, "Argument attribute value did not match", TestAnyWith, { + 'mystr': 'wrong value'}) + + TestAnyWith({'mydict': { + 'item': 'value', + }}) + self.assertRaisesRegex( + AssertionError, "Argument attribute type did not match", TestAnyWith, { + 'mydict': 'value'}) + self.assertRaisesRegex(AssertionError, "Argument attribute value did not match", TestAnyWith, {'mydict': { + 'item-does-not-exist': 'value' + }}) + self.assertRaisesRegex(AssertionError, "Argument attribute value did not match", TestAnyWith, {'mydict': { + 'item': None + }}) + self.assertRaisesRegex(AssertionError, "Argument attribute value did not match", TestAnyWith, {'mydict': { + 'item': 'wrong value' + }}) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/test/test_travis.py b/contrib/buildbot/test/test_travis.py new file mode 100755 index 000000000..fa3242bc1 --- /dev/null +++ b/contrib/buildbot/test/test_travis.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from build import BuildStatus +import json +import test.mocks.travis +import unittest + + +class TravisTestCase(unittest.TestCase): + def setUp(self): + self.travis = test.mocks.travis.instance() + + def tearDown(self): + pass + + def test_get_branch_status(self): + repo_id = 1234 + branch_name = 'test_branch' + + def configure_status(current, previous=None): + if not previous: + previous = 'failed' + + self.travis.session.send.return_value.content = json.dumps({ + "last_build": { + "state": current, + "previous_state": previous, + } + }) + + # No last_build data + self.travis.session.send.return_value.content = json.dumps({ + "last_build": { + } + }) + status = self.travis.get_branch_status(repo_id, branch_name) + self.assertEqual(status, BuildStatus.Failure) + + # Current status is success + configure_status('passed') + status = self.travis.get_branch_status(repo_id, branch_name) + self.assertEqual(status, BuildStatus.Success) + + # Current status is failure + configure_status('failed') + status = self.travis.get_branch_status(repo_id, branch_name) + self.assertEqual(status, BuildStatus.Failure) + + # Current status is errored + configure_status('errored') + status = self.travis.get_branch_status(repo_id, branch_name) + self.assertEqual(status, BuildStatus.Failure) + + # Current status is started, the previous status is success + configure_status('started', 'passed') + status = self.travis.get_branch_status(repo_id, branch_name) + self.assertEqual(status, BuildStatus.Success) + + # Current status is started, the previous status is failure + configure_status('started', 'failed') + status = self.travis.get_branch_status(repo_id, branch_name) + self.assertEqual(status, BuildStatus.Failure) + + # Current status is started, the previous status is unknown + configure_status('started', 'unknown') + status = self.travis.get_branch_status(repo_id, branch_name) + self.assertEqual(status, BuildStatus.Failure) + + +if __name__ == '__main__': + unittest.main() diff --git a/contrib/buildbot/testutil.py b/contrib/buildbot/testutil.py new file mode 100755 index 000000000..2a273333e --- /dev/null +++ b/contrib/buildbot/testutil.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019-2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from pprint import pformat + + +def AnyWith(cls, attrs=None): + class AnyWith(cls): + def __eq__(self, other): + if not isinstance(other, cls): + raise AssertionError("Argument class type did not match.\nExpected:\n{}\n\nActual:\n{}".format( + pformat(cls), pformat(other))) + if attrs is not None: + for attr, expectedValue in attrs.items(): + if not hasattr(other, attr): + raise AssertionError("Argument missing expected attribute:\n{}\n\nArgument has:\n{}".format( + pformat(attr), pformat(dir(other)))) + actualValue = getattr(other, attr) + if not isinstance(expectedValue, type(actualValue)): + raise AssertionError( + "Argument attribute type did not match.\nExpected:\n{}\n\nActual:\n{}\nFor expected value:\n{}".format( + type(expectedValue).__name__, + type(actualValue).__name__, + pformat(expectedValue))) + if expectedValue != actualValue: + raise AssertionError("Argument attribute value did not match.\nExpected:\n{}\n\nActual:\n{}".format( + pformat(expectedValue), pformat(actualValue))) + return True + return AnyWith() diff --git a/contrib/buildbot/travis.py b/contrib/buildbot/travis.py new file mode 100755 index 000000000..d893b54f2 --- /dev/null +++ b/contrib/buildbot/travis.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin ABC developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from build import BuildStatus +import json +import requests +from urllib.parse import urljoin + + +class Travis(): + def __init__(self, base_url="https://api.travis-ci.org", api_version=3): + self.base_url = base_url + self.api_version = api_version + self.session = requests.Session() + self.logger = None + + def set_logger(self, logger): + self.logger = logger + + def get_branch_status(self, repo_id, branch_name): + endpoint = 'repo/{}/branch/{}'.format(repo_id, branch_name) + url = urljoin(self.base_url, endpoint) + + request = self.request('GET', url) + response = self.session.send(request.prepare()) + + if response.status_code != requests.codes.ok: + raise AssertionError( + "Travis get_branch_status() failed\nRequest:\n{}\nResponse:\n{}".format( + vars(request), + vars(response), + ) + ) + + data = json.loads(response.content) + + failure_status = ['failed', 'errored'] + success_status = ['passed'] + + # If the last build is not finished, use the previous status + status = data['last_build'].get('state', None) + if status not in failure_status + success_status: + status = data['last_build'].get('previous_state', None) + + return BuildStatus.Success if status in success_status else BuildStatus.Failure + + def request(self, verb, url, data=None, headers=None): + if self.logger: + self.logger.info('{}: {}'.format(verb, url)) + + if headers is None: + headers = { + 'Content-Type': 'application/json', + 'Travis-API-Version': '3', + } + + req = requests.Request( + verb, + url, + headers=headers) + req.data = data + + return req