def test_restclient(self): """Test use of RestClient.""" if self.skip_github_tests: print("Skipping test_restclient, no GitHub token available?") return client = RestClient('https://api.github.com', username=GITHUB_TEST_ACCOUNT, token=self.github_token) status, body = client.repos['easybuilders']['testrepository'].contents.a_directory['a_file.txt'].get() self.assertEqual(status, 200) # base64.b64encode requires & produces a 'bytes' value in Python 3, # but we need a string value hence the .decode() (also works in Python 2) self.assertEqual(body['content'].strip(), base64.b64encode(b'this is a line of text\n').decode()) status, headers = client.head() self.assertEqual(status, 200) self.assertTrue(headers) self.assertTrue('X-GitHub-Media-Type' in headers) httperror_hit = False try: status, body = client.user.emails.post(body='*****@*****.**') self.assertTrue(False, 'posting to unauthorized endpoint did not throw a http error') except HTTPError: httperror_hit = True self.assertTrue(httperror_hit, "expected HTTPError not encountered") httperror_hit = False try: status, body = client.user.emails.delete(body='*****@*****.**') self.assertTrue(False, 'deleting to unauthorized endpoint did not throw a http error') except HTTPError: httperror_hit = True self.assertTrue(httperror_hit, "expected HTTPError not encountered")
def main(): types = { 'dump': dump_data, 'html': gen_pr_overview_page, 'plot': plot_pr_stats, 'print': pr_overview, } opts = { 'github-account': ("GitHub account where repository is located", None, 'store', 'easybuilders', 'a'), 'github-user': ("GitHub user to use (for authenticated access)", None, 'store', 'boegel', 'u'), 'range': ("Range for PRs to take into account", None, 'store', None, 'x'), 'repository': ("Repository to use", None, 'store', 'easybuild-easyconfigs', 'r'), 'since': ("Date to use to select range of issues for which to pull in data (e.g. 2019-10-24)", None, 'store', None, 's'), 'type': ("Type of overview: 'dump', 'plot', 'print', or 'html'", 'choice', 'store_or_None', 'print', list(types.keys()), 't'), 'update': ("Update existing data", None, 'store_true', False), } go = simple_option(go_dict=opts, descr="Script to print overview of pull requests for a GitHub repository") github_account = go.options.github_account github_user = go.options.github_user pr_range = None if go.options.range: print(go.options.range) range_regex = re.compile('^[0-9]+-[0-9]+$') if range_regex.match(go.options.range): pr_range = go.options.range.split('-') else: sys.stderr.write("Range '%s' does not match pattern '%s'\n" % (go.options.range, range_regex.pattern)) sys.exit(1) github_token = fetch_github_token(github_user) github = RestClient(GITHUB_API_URL, username=github_user, token=github_token, user_agent='eb-pr-overview') pickle_file = None if go.args: pickle_file = go.args[0] downloading_msg = "Downloading PR data for %s/%s repo..." % (github_account, go.options.repository) print(downloading_msg) prs = fetch_prs_data(pickle_file, github, github_account, go.options.repository, downloading_msg, pr_range=pr_range, update=go.options.update, since=go.options.since) if go.options.type in types: types[go.options.type](prs, go)
def main(): """the main function""" fancylogger.logToScreen(enable=True, stdout=True) fancylogger.setLogLevelInfo() options = { 'github-user': ('Your github username to use', None, 'store', None, 'g'), 'closed-pr': ('Delete all gists from closed pull-requests', None, 'store_true', True, 'p'), 'all': ('Delete all gists from Easybuild ', None, 'store_true', False, 'a'), 'orphans': ('Delete all gists without a pull-request', None, 'store_true', False, 'o'), 'dry-run': ("Only show which gists will be deleted but don't actually delete them", None, 'store_true', False), } go = simple_option(options) log = go.log if not (go.options.all or go.options.closed_pr or go.options.orphans): raise EasyBuildError("Please tell me what to do?") if go.options.github_user is None: EasyBuildOptions.DEFAULT_LOGLEVEL = None # Don't overwrite log level eb_go = EasyBuildOptions(envvar_prefix='EASYBUILD', go_args=[]) username = eb_go.options.github_user log.debug("Fetch github username from easybuild, found: %s", username) else: username = go.options.github_user if username is None: raise EasyBuildError("Could not find a github username") else: log.info("Using username = %s", username) token = fetch_github_token(username) gh = RestClient(GITHUB_API_URL, username=username, token=token) all_gists = [] cur_page = 1 while True: status, gists = gh.gists.get(per_page=100, page=cur_page) if status != HTTP_STATUS_OK: raise EasyBuildError( "Failed to get a lists of gists for user %s: error code %s, message = %s", username, status, gists) if gists: all_gists.extend(gists) cur_page += 1 else: break log.info("Found %s gists", len(all_gists)) re_eb_gist = re.compile( r"(EasyBuild test report|EasyBuild log for failed build)(.*?)$") re_pr_nr = re.compile(r"(EB )?PR #([0-9]+)") pr_cache = {} num_deleted = 0 for gist in all_gists: if not gist["description"]: continue gist_match = re_eb_gist.search(gist["description"]) if not gist_match: log.debug("Found a non-Easybuild gist (id=%s)", gist["id"]) continue log.debug("Found an Easybuild gist (id=%s)", gist["id"]) pr_data = gist_match.group(2) pr_nrs_matches = re_pr_nr.findall(pr_data) if go.options.all: delete_gist = True elif not pr_nrs_matches: log.debug("Found Easybuild test report without PR (id=%s).", gist["id"]) delete_gist = go.options.orphans elif go.options.closed_pr: # All PRs must be closed delete_gist = True for pr_nr_match in pr_nrs_matches: eb_str, pr_num = pr_nr_match if eb_str or GITHUB_EASYBLOCKS_REPO in pr_data: repo = GITHUB_EASYBLOCKS_REPO else: repo = GITHUB_EASYCONFIGS_REPO cache_key = "%s-%s" % (repo, pr_num) if cache_key not in pr_cache: try: status, pr = gh.repos[GITHUB_EB_MAIN][repo].pulls[ pr_num].get() except HTTPError as e: status, pr = e.code, e.msg if status != HTTP_STATUS_OK: raise EasyBuildError( "Failed to get pull-request #%s: error code %s, message = %s", pr_num, status, pr) pr_cache[cache_key] = pr["state"] if pr_cache[cache_key] == "closed": log.debug("Found report from closed %s PR #%s (id=%s)", repo, pr_num, gist["id"]) elif delete_gist: if len(pr_nrs_matches) > 1: log.debug( "Found at least 1 PR, that is not closed yet: %s/%s (id=%s)", repo, pr_num, gist["id"]) delete_gist = False else: delete_gist = True if delete_gist: if go.options.dry_run: log.info("DRY-RUN: Delete gist with id=%s", gist["id"]) num_deleted += 1 continue try: status, del_gist = gh.gists[gist["id"]].delete() except HTTPError as e: status, del_gist = e.code, e.msg except URLError as e: status, del_gist = None, e.reason if status != HTTP_DELETE_OK: log.warning( "Unable to remove gist (id=%s): error code %s, message = %s", gist["id"], status, del_gist) else: log.info("Deleted gist with id=%s", gist["id"]) num_deleted += 1 if go.options.dry_run: log.info("DRY-RUN: Would delete %s gists", num_deleted) else: log.info("Deleted %s gists", num_deleted)
def main(): """the main function""" fancylogger.logToScreen(enable=True, stdout=True) fancylogger.setLogLevelInfo() options = { 'github-user': ('Your github username to use', None, 'store', None, 'g'), 'closed-pr': ('Delete all gists from closed pull-requests', None, 'store_true', True, 'p'), 'all': ('Delete all gists from Easybuild ', None, 'store_true', False, 'a'), 'orphans': ('Delete all gists without a pull-request', None, 'store_true', False, 'o'), } go = simple_option(options) log = go.log if not (go.options.all or go.options.closed_pr or go.options.orphans): raise EasyBuildError("Please tell me what to do?") if go.options.github_user is None: eb_go = EasyBuildOptions(envvar_prefix='EASYBUILD', go_args=[]) username = eb_go.options.github_user log.debug("Fetch github username from easybuild, found: %s", username) else: username = go.options.github_user if username is None: raise EasyBuildError("Could not find a github username") else: log.info("Using username = %s", username) token = fetch_github_token(username) gh = RestClient(GITHUB_API_URL, username=username, token=token) all_gists = [] cur_page = 1 while True: status, gists = gh.gists.get(per_page=100, page=cur_page) if status != HTTP_STATUS_OK: raise EasyBuildError("Failed to get a lists of gists for user %s: error code %s, message = %s", username, status, gists) if gists: all_gists.extend(gists) cur_page += 1 else: break log.info("Found %s gists", len(all_gists)) regex = re.compile(r"(EasyBuild test report|EasyBuild log for failed build).*?(?:PR #(?P<PR>[0-9]+))?\)?$") pr_cache = {} num_deleted = 0 for gist in all_gists: if not gist["description"]: continue re_pr_num = regex.search(gist["description"]) delete_gist = False if re_pr_num: log.debug("Found a Easybuild gist (id=%s)", gist["id"]) pr_num = re_pr_num.group("PR") if go.options.all: delete_gist = True elif pr_num and go.options.closed_pr: log.debug("Found Easybuild test report for PR #%s", pr_num) if pr_num not in pr_cache: status, pr = gh.repos[GITHUB_EB_MAIN][GITHUB_EASYCONFIGS_REPO].pulls[pr_num].get() if status != HTTP_STATUS_OK: raise EasyBuildError("Failed to get pull-request #%s: error code %s, message = %s", pr_num, status, pr) pr_cache[pr_num] = pr["state"] if pr_cache[pr_num] == "closed": log.debug("Found report from closed PR #%s (id=%s)", pr_num, gist["id"]) delete_gist = True elif not pr_num and go.options.orphans: log.debug("Found Easybuild test report without PR (id=%s)", gist["id"]) delete_gist = True if delete_gist: status, del_gist = gh.gists[gist["id"]].delete() if status != HTTP_DELETE_OK: raise EasyBuildError("Unable to remove gist (id=%s): error code %s, message = %s", gist["id"], status, del_gist) else: log.info("Delete gist with id=%s", gist["id"]) num_deleted += 1 log.info("Deleted %s gists", num_deleted)
def main(): opts = { 'core-cnt': ("Default core count to use for jobs", None, 'store', None), 'github-account': ("GitHub account where repository is located", None, 'store', 'easybuilders', 'a'), 'github-user': ("GitHub user to use (for authenticated access)", None, 'store', 'boegel', 'u'), 'mode': ("Mode to run in", 'choice', 'store', MODE_CHECK_TRAVIS, [MODE_CHECK_GITHUB_ACTIONS, MODE_CHECK_TRAVIS, MODE_TEST_PR]), 'owner': ("Owner of the bot account that is used", None, 'store', 'boegel'), 'repository': ("Repository to use", None, 'store', 'easybuild-easyconfigs', 'r'), 'host': ("Label for current host (used to filter comments asking to test a PR)", None, 'store', ''), 'pr-test-cmd': ("Command to use for testing easyconfig pull requests (should include '%(pr)s' template value)", None, 'store', ''), } go = simple_option(go_dict=opts) init_build_options() github_account = go.options.github_account github_user = go.options.github_user mode = go.options.mode owner = go.options.owner owner = go.options.owner repository = go.options.repository host = go.options.host pr_test_cmd = go.options.pr_test_cmd core_cnt = go.options.core_cnt github_token = fetch_github_token(github_user) # prepare using GitHub API github = RestClient(GITHUB_API_URL, username=github_user, token=github_token, user_agent='eb-pr-check') if mode in [MODE_CHECK_GITHUB_ACTIONS, MODE_CHECK_TRAVIS]: if mode == MODE_CHECK_TRAVIS: res = fetch_travis_failed_builds(github_account, repository, owner, github_token) elif mode == MODE_CHECK_GITHUB_ACTIONS: res = fetch_github_failed_workflows(github, github_account, repository, github_user, owner) else: error("Unknown mode: %s" % mode) for pr, pr_comment, check_msg in res: params = {'per_page': GITHUB_MAX_PER_PAGE} pr_data, _ = fetch_pr_data(pr, github_account, repository, github_user, full=True, **params) if pr_data['state'] == GITHUB_PR_STATE_OPEN: comment(github, github_user, repository, pr_data, pr_comment, check_msg=check_msg, verbose=DRY_RUN) else: print("Not posting comment in already closed %s PR #%s" % (repository, pr)) elif mode == MODE_TEST_PR: if not host: error("--host is required when using '--mode %s' !" % MODE_TEST_PR) if '%(pr)s' not in pr_test_cmd or '%(eb_args)s' not in pr_test_cmd: error( "--pr-test-cmd should include '%%(pr)s' and '%%(eb_args)s', found '%s'" % (pr_test_cmd)) if core_cnt is None: error( "--core-cnt must be used to specify the default number of cores to request per submitted job!" ) notifications = check_notifications(github, github_user, github_account, repository) process_notifications(notifications, github, github_user, github_account, repository, host, pr_test_cmd, core_cnt) else: error("Unknown mode: %s" % mode)
def fetch_github_failed_workflows(github, github_account, repository, github_user, owner): """Scan GitHub Actions for failed workflow runs.""" res = [] # only consider failed workflows triggered by pull requests params = { 'event': 'pull_request', # filtering based on status='failure' no longer works correctly?! # also with status='completed' some workflow runs are not included in result... # 'status': 'failure', 'per_page': GITHUB_MAX_PER_PAGE, } try: status, run_data = github.repos[github_account][ repository].actions.runs.get(**params) except socket.gaierror as err: error("Failed to download GitHub Actions workflow runs data: %s" % err) if status == 200: run_data = list(run_data['workflow_runs']) print("Found %s failed workflow runs for %s/%s" % (len(run_data), github_account, repository)) else: error( "Status for downloading GitHub Actions workflow runs data should be 200, got %s" % status) failing_prs = set() for idx, entry in enumerate(run_data): if entry['status'] != 'completed': print("Ignoring incomplete workflow run %s" % entry['html_url']) continue if entry['conclusion'] == 'success': print("Ignoring successful workflow run %s" % entry['html_url']) continue head_user = entry['head_repository']['owner']['login'] head = '%s:%s' % (head_user, entry['head_branch']) head_sha = entry['head_sha'] # determine corresponding PR (if any) status, pr_data = github.repos[github_account][repository].pulls.get( head=head) if status != 200: error( "Status for downloading data for PR with head %s should be 200, got %s" % (head, status)) if len(pr_data) == 1: pr_data = pr_data[0] print("Failed workflow run %s found (PR: %s)" % (entry['html_url'], pr_data['html_url'])) pr_id = pr_data['number'] # skip PRs for which a failing workflow was already encountered if pr_id in failing_prs: print("PR #%s already encountered, so skipping workflow %s" % (pr_id, entry['html_url'])) continue pr_data, _ = fetch_pr_data(pr_id, github_account, repository, github_user, full=True, per_page=GITHUB_MAX_PER_PAGE) if pr_data['state'] == 'open': pr_head_sha = pr_data['head']['sha'] # make sure workflow was run for latest commit in this PR if head_sha != pr_head_sha: msg = "Workflow %s was for commit %s, " % ( entry['html_url'], head_sha) msg += "not latest commit in PR #%s (%s), so skipping" % ( pr_id, pr_head_sha) print(msg) continue # check status of most recent commit in this PR, # ignore this PR if status is "success" or "pending" pr_status = pr_data['status_last_commit'] print("Status of last commit (%s) in PR #%s: %s" % (pr_head_sha, pr_id, pr_status)) if pr_status in [ 'action_required', STATUS_PENDING, STATUS_SUCCESS ]: print( "Status of last commit in PR #%s is '%s', so ignoring it for now..." % (pr_id, pr_status)) continue # download list of jobs in workflow run_id = entry['id'] status, jobs_data = github.repos[github_account][ repository].actions.runs[run_id].jobs.get() if status != 200: error( "Failed to download list of jobs for workflow run %s" % entry['html_url']) # determine ID of first failing job job_id = None for job in jobs_data['jobs']: if job['conclusion'] == 'failure': job_id = job['id'] print("Found failing job for workflow %s: %s" % (entry['html_url'], job_id)) break if job_id is None: error("ID of failing job not found for workflow %s" % entry['html_url']) try: status, log_txt = github.repos[github_account][ repository].actions.jobs[job_id].logs.get() except HTTPError as err: status = err.code if status == 200: print("Downloaded log for job %s" % job_id) else: warning("Failed to download log for job %s" % job_id) log_txt = '(failed to fetch log contents due to HTTP status code %s)' % status # strip off timestamp prefixes # example timestamp: 2020-07-13T09:54:36.5004935Z timestamp_regex = re.compile( r'^[0-9-]{10}T[0-9:]{8}\.[0-9]+Z ') log_lines = [ timestamp_regex.sub('', x) for x in log_txt.splitlines() ] # determine line that marks end of output for failing test suite: # "ERROR: Not all tests were successful" error_line_idx = None for idx, line in enumerate(log_lines): if line.startswith("ERROR: Not all tests were successful"): error_line_idx = idx print("Found error line @ index %s" % error_line_idx) break if error_line_idx is None: log_txt_clean = '\n'.join(log_lines) warning( "Log line that marks end of test suite output not found for job %s!\n%s" % (job_id, log_txt_clean)) if is_fluke(log_txt): owner_gh_token = fetch_github_token(owner) if owner_gh_token: github_owner = RestClient(GITHUB_API_URL, username=owner, token=owner_gh_token, user_agent='eb-pr-check') print( "Fluke found, restarting this workflow using @%s's GitHub account..." % owner) repo_api = github_owner.repos[github_account][ repository] status, jobs_data = repo_api.actions.runs[ run_id].rerun.post() if status == 201: print("Workflow %s restarted" % entry['html_url']) else: print( "Failed to restart workflow %s: status %s" % (entry['html_url'], status)) else: warning( "Fluke found but can't restart workflow, no token found for @%s" % owner) continue # find line that marks start of test output: only dots and 'E'/'F' characters start_test_regex = re.compile(r'^[\.EF]+$') start_line_idx = error_line_idx start_log_line = log_lines[start_line_idx] while (start_line_idx >= 0 and not (start_log_line and start_test_regex.match(start_log_line))): start_line_idx -= 1 start_log_line = log_lines[start_line_idx] log_lines = log_lines[start_line_idx + 1:error_line_idx + 1] # compose comment pr_comment = "@%s: Tests failed in GitHub Actions" % pr_data[ 'user']['login'] pr_comment += ", see %s" % entry['html_url'] # use first part of comment to check whether comment was already posted check_msg = pr_comment if len(log_lines) > 100: log_lines = log_lines[-100:] pr_comment += "\nLast 100 lines of output from first failing test suite run:\n\n```" else: pr_comment += "\nOutput from first failing test suite run:\n\n```" for line in log_lines: pr_comment += line + '\n' pr_comment += "```\n" pr_comment += "\n*bleep, bloop, I'm just a bot (boegelbot v%s)*\n" % VERSION pr_comment += "Please talk to my owner `@%s` if you notice you me acting stupid),\n" % owner pr_comment += "or submit a pull request to https://github.com/boegel/boegelbot fix the problem." res.append((pr_id, pr_comment, check_msg)) failing_prs.add(pr_id) else: print("Ignoring failed workflow run for closed PR %s" % pr_data['html_url']) else: warning("Expected exactly one PR with head %s, found %s: %s" % (head, len(pr_data), pr_data)) print("Processed %d failed workflow runs, found %d PRs to report back on" % (len(run_data), len(res))) return res