import functools
from collections import namedtuple
from types import FunctionType
from mozilla_repo_urls import parse
from taskgraph import create
from taskgraph.config import load_graph_config
from taskgraph.parameters import Parameters
from taskgraph.util import hash, json, taskcluster, yaml
from taskgraph.util.python_path import import_sibling_modules
actions = []
callbacks = {}
Action = namedtuple("Action", ["order", "cb_name", "permission", "action_builder"])
def is_json(data):
try:
json.dumps(data)
except ValueError:
return False
return True
@functools.lru_cache(maxsize=None)
def read_taskcluster_yml(filename):
return yaml.load_yaml(filename)
@functools.lru_cache(maxsize=None)
def hash_taskcluster_yml(filename):
return hash.hash_path(filename)[:10]
def register_callback_action(
name,
title,
symbol,
description,
order=10000,
context=[],
available=lambda parameters: True,
schema=None,
permission="generic",
cb_name=None,
):
mem = {"registered": False}
assert isinstance(title, str), "title must be a string"
assert isinstance(description, str), "description must be a string"
title = title.strip()
description = description.strip()
if not cb_name:
cb_name = name
if not callable(context):
context_value = context
context = lambda params: context_value
def register_callback(cb):
assert isinstance(name, str), "name must be a string"
assert isinstance(order, int), "order must be an integer"
assert callable(schema) or is_json(schema), (
"schema must be a JSON compatible object"
)
assert isinstance(cb, FunctionType), "callback must be a function"
if "$" not in symbol:
assert 1 <= len(symbol) <= 25, "symbol must be between 1 and 25 characters"
assert isinstance(symbol, str), "symbol must be a string"
assert not mem["registered"], (
"register_callback_action must be used as decorator"
)
assert cb_name not in callbacks, f"callback name {cb_name} is not unique"
def action_builder(parameters, graph_config, decision_task_id):
if not available(parameters):
return None
repository = {
"url": parameters["head_repository"],
"project": parameters["project"],
"level": parameters["level"],
"base_url": parameters["base_repository"],
}
push = {
"owner": "mozilla-taskcluster-maintenance@mozilla.com",
"pushlog_id": parameters["pushlog_id"],
"revision": parameters["head_rev"],
"base_revision": parameters["base_rev"],
}
branch = parameters.get("head_ref")
if branch:
push["branch"] = branch
base_branch = parameters.get("base_ref")
if base_branch and branch != base_branch:
push["base_branch"] = base_branch
action = {
"name": name,
"title": title,
"description": description,
"taskGroupId": decision_task_id,
"cb_name": cb_name,
"symbol": symbol,
}
rv = {
"name": name,
"title": title,
"description": description,
"context": context(parameters),
}
if schema:
rv["schema"] = (
schema(graph_config=graph_config) if callable(schema) else schema
)
trustDomain = graph_config["trust-domain"]
level = parameters["level"]
tcyml_hash = hash_taskcluster_yml(graph_config.taskcluster_yml)
if "/" in permission:
raise Exception("`/` is not allowed in action names; use `-`")
if parameters["tasks_for"].startswith("github-pull-request"):
hookId = f"in-tree-pr-action-{level}-{permission}/{tcyml_hash}"
else:
hookId = f"in-tree-action-{level}-{permission}/{tcyml_hash}"
rv.update(
{
"kind": "hook",
"hookGroupId": f"project-{trustDomain}",
"hookId": hookId,
"hookPayload": {
"decision": {
"action": action,
"repository": repository,
"push": push,
},
"user": {
"input": {"$eval": "input"},
"taskId": {"$eval": "taskId"}, "taskGroupId": {
"$eval": "taskGroupId"
}, },
},
"extra": {
"actionPerm": permission,
},
}
)
return rv
actions.append(Action(order, cb_name, permission, action_builder))
mem["registered"] = True
callbacks[cb_name] = cb
return cb
return register_callback
def render_actions_json(parameters, graph_config, decision_task_id):
assert isinstance(parameters, Parameters), "requires instance of Parameters"
actions = []
for action in sorted(_get_actions(graph_config), key=lambda action: action.order):
action = action.action_builder(parameters, graph_config, decision_task_id)
if action:
assert is_json(action), "action must be a JSON compatible object"
actions.append(action)
return {
"version": 1,
"variables": {},
"actions": actions,
}
def sanity_check_task_scope(callback, parameters, graph_config):
for action in _get_actions(graph_config):
if action.cb_name == callback:
break
else:
raise ValueError(f"No action with cb_name {callback}")
parsed_base_url = parse(parameters["base_repository"])
parsed_head_url = parse(parameters["head_repository"])
action_scope = (
f"assume:{parsed_head_url.taskcluster_role_prefix}:action:{action.permission}"
)
pr_action_scope = f"assume:{parsed_base_url.taskcluster_role_prefix}:pr-action:{action.permission}"
if not set((action_scope, pr_action_scope)) & set(taskcluster.get_current_scopes()):
raise ValueError(
f"Expected task scope {action_scope} or {pr_action_scope} for this action"
)
def trigger_action_callback(
task_group_id, task_id, input, callback, parameters, root, test=False
):
graph_config = load_graph_config(root)
graph_config.register()
callbacks = _get_callbacks(graph_config)
cb = callbacks.get(callback, None)
if not cb:
raise Exception(
"Unknown callback: {}. Known callbacks: {}".format(
callback, ", ".join(callbacks)
)
)
if test:
create.testing = True
taskcluster.testing = True
if not test:
sanity_check_task_scope(callback, parameters, graph_config)
cb(Parameters(**parameters), graph_config, input, task_group_id, task_id)
def _load(graph_config):
import_sibling_modules(exceptions=("util.py",))
return callbacks, actions
def _get_callbacks(graph_config):
return _load(graph_config)[0]
def _get_actions(graph_config):
return _load(graph_config)[1]