def _fmt_error_message(errors, version): msg = ( "The recipe did not change in the version migration, a URL did " "not hash, or there is jinja2 syntax the bot cannot handle!\n\n" "Please check the URLs in your recipe with version '%s' to make sure " "they exist!\n\n" % version) if len(errors) > 0: msg += "We also found the following errors:\n\n - %s" % ("\n - ".join( e for e in errors)) msg += "\n" return sanitize_string(msg)
def _set_pre_pr_migrator_fields(attrs, migrator_name, error_str): pre_key = "pre_pr_migrator_status" pre_key_att = "pre_pr_migrator_attempts" if pre_key not in attrs: attrs[pre_key] = {} attrs[pre_key][migrator_name] = sanitize_string( error_str, ) if pre_key_att not in attrs: attrs[pre_key_att] = {} if migrator_name not in attrs[pre_key_att]: attrs[pre_key_att][migrator_name] = 0 attrs[pre_key_att][migrator_name] += 1
def migrate( self, recipe_dir: str, attrs: "AttrsTypedDict", hash_type: str = "sha256", **kwargs: Any, ) -> "MigrationUidTypedDict": errors = set() version = attrs["new_version"] # record the attempt if "new_version_attempts" not in attrs: attrs["new_version_attempts"] = {} if version not in attrs["new_version_attempts"]: attrs["new_version_attempts"][version] = 0 attrs["new_version_attempts"][version] += 1 if "new_version_errors" not in attrs: attrs["new_version_errors"] = {} if not isinstance(version, str): errors.add( "the version '%s' is not a string and must be for the bot" % version, ) attrs["new_version_errors"][version] = _fmt_error_message(errors, version) logger.critical( "the version '%s' is not a string and must be for the bot", version, ) return {} try: with open(os.path.join(recipe_dir, "meta.yaml")) as fp: cmeta = CondaMetaYAML(fp.read()) except Exception as e: tb = io.StringIO() traceback.print_tb(e.__traceback__, file=tb) tb.seek(0) tb = tb.read() attrs["new_version_errors"][version] = sanitize_string( "We found a problem parsing the recipe for version '" + version + "': \n\n" + repr(e) + "\n\ntraceback:\n" + tb, ) logger.critical( "We found a problem parsing the recipe: \n\n%s\n\n%s", str(e), tb, ) return {} # cache round-tripped yaml for testing later s = io.StringIO() cmeta.dump(s) s.seek(0) old_meta_yaml = s.read() # if is a git url, then we error if _recipe_has_git_url(cmeta) and not _recipe_has_url(cmeta): logger.critical("Migrations do not work on `git_url`s!") errors.add("migrations do not work on `git_url`s") attrs["new_version_errors"][version] = _fmt_error_message(errors, version) return {} # mangle the version if it is R r_url = False for src_key in _gen_key_selector(cmeta.meta, "source"): r_url |= _has_r_url(cmeta.meta[src_key]) for key, val in cmeta.jinja2_vars.items(): if isinstance(val, str): r_url |= _is_r_url(val) if r_url: version = version.replace("_", "-") # replace the version if "version" in cmeta.jinja2_vars: # cache old version for testing later old_version = cmeta.jinja2_vars["version"] cmeta.jinja2_vars["version"] = version else: logger.critical( "Migrations do not work on versions not specified with jinja2!", ) errors.add("migrations do not work on versions not specified with jinja2") attrs["new_version_errors"][version] = _fmt_error_message(errors, version) return {} if len(list(_gen_key_selector(cmeta.meta, "source"))) > 0: did_update = True for src_key in _gen_key_selector(cmeta.meta, "source"): if isinstance(cmeta.meta[src_key], collections.abc.MutableSequence): for src in cmeta.meta[src_key]: _did_update, _errors = _try_to_update_version( cmeta, src, hash_type, ) if _did_update is not None: did_update &= _did_update errors |= _errors else: _did_update, _errors = _try_to_update_version( cmeta, cmeta.meta[src_key], hash_type, ) if _did_update is not None: did_update &= _did_update errors |= _errors if _errors: logger.critical("%s", _errors) else: did_update = False errors.add("no source sections found in the recipe") logger.critical("no source sections found in the recipe") if did_update: # if the yaml did not change, then we did not migrate actually cmeta.jinja2_vars["version"] = old_version s = io.StringIO() cmeta.dump(s) s.seek(0) still_the_same = s.read() == old_meta_yaml cmeta.jinja2_vars["version"] = version # put back version if still_the_same and old_version != version: did_update = False errors.add( "recipe did not appear to change even " "though the bot said it should have", ) logger.critical( "Recipe did not change in version migration " "but the code indicates an update was done!", ) if did_update: with indir(recipe_dir): with open("meta.yaml", "w") as fp: cmeta.dump(fp) self.set_build_number("meta.yaml") return super().migrate(recipe_dir, attrs) else: logger.critical("Recipe did not change in version migration!") attrs["new_version_errors"][version] = _fmt_error_message(errors, version) return {}
def main(args: "CLIArgs") -> None: # logging if args.debug: setup_logger(logging.getLogger("conda_forge_tick"), level="debug") else: setup_logger(logging.getLogger("conda_forge_tick")) github_username = env.get("USERNAME", "") github_password = env.get("PASSWORD", "") github_token = env.get("GITHUB_TOKEN") mctx, temp, migrators = initialize_migrators( github_username=github_username, github_password=github_password, dry_run=args.dry_run, github_token=github_token, ) # compute the time per migrator print("computing time per migration", flush=True) (num_nodes, time_per_migrator, tot_time_per_migrator) = _compute_time_per_migrator( mctx, migrators, ) for i, migrator in enumerate(migrators): if hasattr(migrator, "name"): extra_name = "-%s" % migrator.name else: extra_name = "" print( " %s%s: %d - gets %f seconds (%f percent)" % ( migrator.__class__.__name__, extra_name, num_nodes[i], time_per_migrator[i], time_per_migrator[i] / max(tot_time_per_migrator, 1) * 100, ), flush=True, ) for mg_ind, migrator in enumerate(migrators): if hasattr(migrator, "name"): assert isinstance(migrator.name, str) migrator_name = migrator.name.lower().replace(" ", "") else: migrator_name = migrator.__class__.__name__.lower() mmctx = MigratorContext(session=mctx, migrator=migrator) migrator.bind_to_ctx(mmctx) good_prs = 0 _mg_start = time.time() effective_graph = mmctx.effective_graph time_per = time_per_migrator[mg_ind] if hasattr(migrator, "name"): extra_name = "-%s" % migrator.name else: extra_name = "" print( "\n========================================" "========================================" "\n" "========================================" "========================================", flush=True, ) print( "Running migrations for %s%s: %d\n" % ( migrator.__class__.__name__, extra_name, len(effective_graph.nodes), ), flush=True, ) possible_nodes = list(migrator.order(effective_graph, mctx.graph)) # version debugging info if isinstance(migrator, Version): LOGGER.info("possible version migrations:") for node_name in possible_nodes: with effective_graph.nodes[node_name]["payload"] as attrs: LOGGER.info( " node|curr|new|attempts: %s|%s|%s|%d", node_name, attrs.get("version"), attrs.get("new_version"), (attrs.get("new_version_attempts", {}).get( attrs.get("new_version", ""), 0, )), ) for node_name in possible_nodes: with mctx.graph.nodes[node_name]["payload"] as attrs: base_branches = migrator.get_possible_feedstock_branches(attrs) orig_branch = attrs.get("branch", "master") # Don't let CI timeout, break ahead of the timeout so we make certain # to write to the repo # TODO: convert these env vars _now = time.time() if ((_now - int(env.get("START_TIME", time.time())) > int( env.get("TIMEOUT", 600))) or good_prs >= migrator.pr_limit or (_now - _mg_start) > time_per): break fctx = FeedstockContext( package_name=node_name, feedstock_name=attrs["feedstock_name"], attrs=attrs, ) try: for base_branch in base_branches: attrs["branch"] = base_branch if migrator.filter(attrs): continue print("\n", flush=True, end="") LOGGER.info( "%s%s IS MIGRATING %s:%s", migrator.__class__.__name__.upper(), extra_name, fctx.package_name, base_branch, ) try: # Don't bother running if we are at zero if mctx.gh_api_requests_left == 0: break migrator_uid, pr_json = run( feedstock_ctx=fctx, migrator=migrator, rerender=migrator.rerender, protocol="https", hash_type=attrs.get("hash_type", "sha256"), base_branch=base_branch, ) # if migration successful if migrator_uid: d = frozen_to_json_friendly(migrator_uid) # if we have the PR already do nothing if d["data"] in [ existing_pr["data"] for existing_pr in attrs.get( "PRed", []) ]: pass else: if not pr_json: pr_json = { "state": "closed", "head": { "ref": "<this_is_not_a_branch>" }, } d["PR"] = pr_json attrs.setdefault("PRed", []).append(d) attrs.update( { "smithy_version": mctx.smithy_version, "pinning_version": mctx.pinning_version, }, ) except github3.GitHubError as e: if e.msg == "Repository was archived so is read-only.": attrs["archived"] = True else: LOGGER.critical( "GITHUB ERROR ON FEEDSTOCK: %s", fctx.feedstock_name, ) if is_github_api_limit_reached(e, mctx.gh): break except URLError as e: LOGGER.exception("URLError ERROR") attrs["bad"] = { "exception": str(e), "traceback": str(traceback.format_exc()).split("\n"), "code": getattr(e, "code"), "url": getattr(e, "url"), } pre_key = "pre_pr_migrator_status" if pre_key not in attrs: attrs[pre_key] = {} attrs[pre_key][migrator_name] = sanitize_string( "bot error (%s): %s: %s" % ( '<a href="' + os.getenv("CIRCLE_BUILD_URL", "") + '">bot CI job</a>', base_branch, str(traceback.format_exc()), ), ) except Exception as e: LOGGER.exception("NON GITHUB ERROR") # we don't set bad for rerendering errors if ("conda smithy rerender -c auto --no-check-uptodate" not in str(e)): attrs["bad"] = { "exception": str(e), "traceback": str(traceback.format_exc()).split("\n", ), } pre_key = "pre_pr_migrator_status" if pre_key not in attrs: attrs[pre_key] = {} attrs[pre_key][migrator_name] = sanitize_string( "bot error (%s): %s: %s" % ( '<a href="' + os.getenv("CIRCLE_BUILD_URL", "") + '">bot CI job</a>', base_branch, str(traceback.format_exc()), ), ) else: if migrator_uid: # On successful PR add to our counter good_prs += 1 finally: # reset branch attrs["branch"] = orig_branch # Write graph partially through if not args.dry_run: dump_graph(mctx.graph) eval_cmd(f"rm -rf {mctx.rever_dir}/*") LOGGER.info(os.getcwd()) for f in glob.glob("/tmp/*"): if f not in temp: try: eval_cmd(f"rm -rf {f}") except Exception: pass if mctx.gh_api_requests_left == 0: break print("\n", flush=True) LOGGER.info("API Calls Remaining: %d", mctx.gh_api_requests_left) LOGGER.info("Done")
def run( feedstock_ctx: FeedstockContext, migrator: Migrator, protocol: str = "ssh", pull_request: bool = True, rerender: bool = True, fork: bool = True, base_branch: str = "master", **kwargs: typing.Any, ) -> Tuple["MigrationUidTypedDict", dict]: """For a given feedstock and migration run the migration Parameters ---------- feedstock_ctx: FeedstockContext The node attributes migrator: Migrator instance The migrator to run on the feedstock protocol : str, optional The git protocol to use, defaults to ``ssh`` pull_request : bool, optional If true issue pull request, defaults to true rerender : bool Whether to rerender fork : bool If true create a fork, defaults to true base_branch : str, optional The base branch to which the PR will be targeted. Defaults to "master". kwargs: dict The key word arguments to pass to the migrator Returns ------- migrate_return: MigrationUidTypedDict The migration return dict used for tracking finished migrations pr_json: dict The PR json object for recreating the PR as needed """ # get the repo # TODO: stop doing this. migrator.attrs = feedstock_ctx.attrs # type: ignore branch_name = migrator.remote_branch( feedstock_ctx) + "_h" + uuid4().hex[0:6] if hasattr(migrator, "name"): assert isinstance(migrator.name, str) migrator_name = migrator.name.lower().replace(" ", "") else: migrator_name = migrator.__class__.__name__.lower() # TODO: run this in parallel feedstock_dir, repo = get_repo( ctx=migrator.ctx.session, fctx=feedstock_ctx, branch=branch_name, feedstock=feedstock_ctx.feedstock_name, protocol=protocol, pull_request=pull_request, fork=fork, base_branch=base_branch, ) if not feedstock_dir or not repo: LOGGER.critical( "Failed to migrate %s, %s", feedstock_ctx.package_name, feedstock_ctx.attrs.get("bad"), ) return False, False recipe_dir = os.path.join(feedstock_dir, "recipe") # migrate the feedstock migrator.run_pre_piggyback_migrations(recipe_dir, feedstock_ctx.attrs, **kwargs) # TODO - make a commit here if the repo changed migrate_return = migrator.migrate(recipe_dir, feedstock_ctx.attrs, **kwargs) if not migrate_return: LOGGER.critical( "Failed to migrate %s, %s", feedstock_ctx.package_name, feedstock_ctx.attrs.get("bad"), ) eval_cmd(f"rm -rf {feedstock_dir}") return False, False # TODO - commit main migration here migrator.run_post_piggyback_migrations(recipe_dir, feedstock_ctx.attrs, **kwargs) # TODO commit post migration here # rerender, maybe diffed_files: typing.List[str] = [] with indir(feedstock_dir), env.swap(RAISE_SUBPROC_ERROR=False): msg = migrator.commit_message(feedstock_ctx) # noqa try: eval_cmd("git add --all .") eval_cmd(f"git commit -am '{msg}'") except CalledProcessError as e: LOGGER.info( "could not commit to feedstock - " "likely no changes - error is '%s'" % (repr(e)), ) if rerender: head_ref = eval_cmd("git rev-parse HEAD").strip() LOGGER.info("Rerendering the feedstock") try: eval_cmd( "conda smithy rerender -c auto --no-check-uptodate", timeout=300, ) make_rerender_comment = False except Exception as e: # I am trying this bit of code to force these errors # to be surfaced in the logs at the right time. print(f"RERENDER ERROR: {e}", flush=True) if not isinstance(migrator, Version): raise else: # for check solvable or automerge, we always raise rerender errors if feedstock_ctx.attrs["conda-forge.yml"].get( "bot", {}).get( "check_solvable", False, ) or (feedstock_ctx.attrs["conda-forge.yml"].get( "bot", {}).get( "automerge", False, )): raise else: make_rerender_comment = True # If we tried to run the MigrationYaml and rerender did nothing (we only # bumped the build number and dropped a yaml file in migrations) bail # for instance platform specific migrations gdiff = eval_cmd(f"git diff --name-only {head_ref.strip()}...HEAD") diffed_files = [ _ for _ in gdiff.split() if not (_.startswith("recipe") or _.startswith("migrators") or _.startswith("README")) ] else: make_rerender_comment = False if (feedstock_ctx.feedstock_name != "conda-forge-pinning" and base_branch == "master" and (( migrator.check_solvable # we always let stuff in cycles go and feedstock_ctx.attrs["name"] not in getattr( migrator, "cycles", set()) # we always let stuff at the top go and feedstock_ctx.attrs["name"] not in getattr( migrator, "top_level", set()) # for solveability always assume automerge is on. and (feedstock_ctx.attrs["conda-forge.yml"].get("bot", {}).get( "automerge", True))) or feedstock_ctx.attrs["conda-forge.yml"].get("bot", {}).get( "check_solvable", False, ))): solvable, errors, _ = is_recipe_solvable( feedstock_dir, build_platform=feedstock_ctx.attrs["conda-forge.yml"].get( "build_platform", None, ), ) if not solvable: _solver_err_str = "not solvable ({}): {}: {}".format( ('<a href="' + os.getenv("CIRCLE_BUILD_URL", "") + '">bot CI job</a>'), base_branch, sorted(set(errors)), ) if isinstance(migrator, Version): _new_ver = feedstock_ctx.attrs["new_version"] if _new_ver in feedstock_ctx.attrs["new_version_errors"]: feedstock_ctx.attrs["new_version_errors"][ _new_ver] += "\n\nsolver error - {}".format( _solver_err_str, ) else: feedstock_ctx.attrs["new_version_errors"][ _new_ver] = _solver_err_str feedstock_ctx.attrs["new_version_errors"][ _new_ver] = sanitize_string( feedstock_ctx.attrs["new_version_errors"][_new_ver], ) # remove part of a try for solver errors to make those slightly # higher priority feedstock_ctx.attrs["new_version_attempts"][_new_ver] -= 0.8 pre_key = "pre_pr_migrator_status" if pre_key not in feedstock_ctx.attrs: feedstock_ctx.attrs[pre_key] = {} feedstock_ctx.attrs[pre_key][migrator_name] = sanitize_string( _solver_err_str, ) eval_cmd(f"rm -rf {feedstock_dir}") return False, False # TODO: Better annotation here pr_json: typing.Union[MutableMapping, None, bool] if (isinstance(migrator, MigrationYaml) and not diffed_files and feedstock_ctx.attrs["name"] != "conda-forge-pinning"): # spoof this so it looks like the package is done pr_json = { "state": "closed", "merged_at": "never issued", "id": str(uuid4()), } else: # push up try: # TODO: remove this hack, but for now this is the only way to get # the feedstock dir into pr_body feedstock_ctx.feedstock_dir = feedstock_dir pr_json = push_repo( session_ctx=migrator.ctx.session, fctx=feedstock_ctx, feedstock_dir=feedstock_dir, body=migrator.pr_body(feedstock_ctx), repo=repo, title=migrator.pr_title(feedstock_ctx), head=f"{migrator.ctx.github_username}:{branch_name}", branch=branch_name, base_branch=base_branch, ) # This shouldn't happen too often any more since we won't double PR except github3.GitHubError as e: if e.msg != "Validation Failed": raise else: print(f"Error during push {e}") # If we just push to the existing PR then do nothing to the json pr_json = False ljpr = False if pr_json and pr_json["state"] != "closed" and make_rerender_comment: comment_on_pr( pr_json, """\ Hi! This feedstock was not able to be rerendered after the version update changes. I have pushed the version update changes anyways and am trying to rerender again with this comment. Hopefully you all can fix this! @conda-forge-admin rerender""", repo, ) if pr_json: ljpr = LazyJson( os.path.join(migrator.ctx.session.prjson_dir, str(pr_json["id"]) + ".json"), ) ljpr.update(**pr_json) # from .dynamo_models import PRJson # PRJson.dump(pr_json) else: ljpr = False # If we've gotten this far then the node is good feedstock_ctx.attrs["bad"] = False LOGGER.info("Removing feedstock dir") eval_cmd(f"rm -rf {feedstock_dir}") return migrate_return, ljpr
def _run_migrator(migrator, mctx, temp, time_per, dry_run): if hasattr(migrator, "name"): assert isinstance(migrator.name, str) migrator_name = migrator.name.lower().replace(" ", "") else: migrator_name = migrator.__class__.__name__.lower() mmctx = MigratorContext(session=mctx, migrator=migrator) migrator.bind_to_ctx(mmctx) good_prs = 0 _mg_start = time.time() effective_graph = mmctx.effective_graph if hasattr(migrator, "name"): extra_name = "-%s" % migrator.name else: extra_name = "" print( "Running migrations for %s%s: %d\n" % ( migrator.__class__.__name__, extra_name, len(effective_graph.nodes), ), flush=True, ) possible_nodes = list(migrator.order(effective_graph, mctx.graph)) # version debugging info if isinstance(migrator, Version): LOGGER.info("possible version migrations:") for node_name in possible_nodes: with effective_graph.nodes[node_name]["payload"] as attrs: LOGGER.info( " node|curr|new|attempts: %s|%s|%s|%f", node_name, attrs.get("version"), attrs.get("new_version"), ( attrs.get("new_version_attempts", {}).get( attrs.get("new_version", ""), 0, ) ), ) for node_name in possible_nodes: with mctx.graph.nodes[node_name]["payload"] as attrs: # Don't let CI timeout, break ahead of the timeout so we make certain # to write to the repo # TODO: convert these env vars _now = time.time() if ( ( _now - int(env.get("START_TIME", time.time())) > int(env.get("TIMEOUT", 600)) ) or good_prs >= migrator.pr_limit or (_now - _mg_start) > time_per ): break base_branches = migrator.get_possible_feedstock_branches(attrs) if "branch" in attrs: has_attrs_branch = True orig_branch = attrs.get("branch") else: has_attrs_branch = False orig_branch = None fctx = FeedstockContext( package_name=node_name, feedstock_name=attrs["feedstock_name"], attrs=attrs, ) # map main to current default branch base_branches = [ br if br != "main" else fctx.default_branch for br in base_branches ] try: for base_branch in base_branches: attrs["branch"] = base_branch if migrator.filter(attrs): continue print("\n", flush=True, end="") sys.stderr.flush() sys.stdout.flush() LOGGER.info( "%s%s IS MIGRATING %s:%s", migrator.__class__.__name__.upper(), extra_name, fctx.package_name, base_branch, ) try: # Don't bother running if we are at zero if mctx.gh_api_requests_left == 0: break migrator_uid, pr_json = run( feedstock_ctx=fctx, migrator=migrator, rerender=migrator.rerender, protocol="https", hash_type=attrs.get("hash_type", "sha256"), base_branch=base_branch, ) # if migration successful if migrator_uid: d = frozen_to_json_friendly(migrator_uid) # if we have the PR already do nothing if d["data"] in [ existing_pr["data"] for existing_pr in attrs.get("PRed", []) ]: pass else: if not pr_json: pr_json = { "state": "closed", "head": {"ref": "<this_is_not_a_branch>"}, } d["PR"] = pr_json attrs.setdefault("PRed", []).append(d) attrs.update( { "smithy_version": mctx.smithy_version, "pinning_version": mctx.pinning_version, }, ) except github3.GitHubError as e: if e.msg == "Repository was archived so is read-only.": attrs["archived"] = True else: LOGGER.critical( "GITHUB ERROR ON FEEDSTOCK: %s", fctx.feedstock_name, ) if is_github_api_limit_reached(e, mctx.gh): break except URLError as e: LOGGER.exception("URLError ERROR") attrs["bad"] = { "exception": str(e), "traceback": str(traceback.format_exc()).split("\n"), "code": getattr(e, "code"), "url": getattr(e, "url"), } _set_pre_pr_migrator_fields( attrs, migrator_name, sanitize_string( "bot error (%s): %s: %s" % ( '<a href="' + os.getenv("CIRCLE_BUILD_URL", "") + '">bot CI job</a>', base_branch, str(traceback.format_exc()), ), ), ) except Exception as e: LOGGER.exception("NON GITHUB ERROR") # we don't set bad for rerendering errors if ( "conda smithy rerender -c auto --no-check-uptodate" not in str(e) ): attrs["bad"] = { "exception": str(e), "traceback": str(traceback.format_exc()).split( "\n", ), } _set_pre_pr_migrator_fields( attrs, migrator_name, sanitize_string( "bot error (%s): %s: %s" % ( '<a href="' + os.getenv("CIRCLE_BUILD_URL", "") + '">bot CI job</a>', base_branch, str(traceback.format_exc()), ), ), ) else: if migrator_uid: # On successful PR add to our counter good_prs += 1 finally: # reset branch if has_attrs_branch: attrs["branch"] = orig_branch # do this but it is crazy gc.collect() # Write graph partially through if not dry_run: dump_graph(mctx.graph) eval_cmd(f"rm -rf {mctx.rever_dir}/*") LOGGER.info(os.getcwd()) for f in glob.glob("/tmp/*"): if f not in temp: try: eval_cmd(f"rm -rf {f}") except Exception: pass if mctx.gh_api_requests_left == 0: break return good_prs