From c25185e041b20a531d49ed97095aa2cb47e0a8eb Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Thu, 24 Jan 2019 14:45:34 +0100 Subject: Add post-update script to trigger builds --- post-update | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100755 post-update (limited to 'post-update') diff --git a/post-update b/post-update new file mode 100755 index 0000000..c824592 --- /dev/null +++ b/post-update @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# Copyright (C) 2019 Robin Krahl +# SPDX-License-Identifier: MIT + +""" +Git post-update hook that triggers builds on builds.sr.ht for all pushed +references. + +The hook tries to find build manifests in the `.build` directory or the +`.build.yml` file. It triggers builds.sr.ht for each manifest and for each +reference that has been pushed. (Multiple references pointing to the same +commit are treated as one reference.) + +This hook requires the following Git configuration (`git config`): +- `sr.ht.token`: a valid access token, see [meta.sr.ht OAuth API][] +- `sr.ht.url`: the URL of the builds.sr.ht instance (defaults to builds.sr.ht) +- `sr.ht.user`: the name of the user running the builds + +You can customize the build manifest before it is submitted by changing the +`customize_manifest`. For example, you can add triggers based on the branch +being pushed to: + +``` +def customize_manifest(repo, manifest, commit, refs): + manifest.setdefault('triggers', []) + if "master" in refs: + manifest["triggers"].append({ + "action": "email", + "condition": "failure", + "to": "~example/test-dev@lists.sr.ht" + }) + else: + manifest["triggers"].append({ + "action": "email", + "condition": "failure", + "to": commit.author.email, + }) + return manifest +``` + +Requirements: +- Python 3 with + - pygit2 + - pyyaml + - requests + +TODO: +- Add timeout for HTTP requests. +- Better source handling (currently, the commit is only set for the first + source). +- Better access code handling. + +[meta.sr.ht OAuth API]: https://man.sr.ht/meta.sr.ht/oauth-api.md +""" + + +import html +import os +import os.path +import pygit2 +import requests +import string +import sys +import yaml + + +def customize_manifest(repo, manifest, commit, refs): + """ + Customize the *manifest* based on the current *commit* and the *refs* that + have been pushed to the *repo*. + """ + return manifest + + +def get_config(repo): + """ + Read and return the sr.ht configuration from the *repo*. + """ + config = {"url": "https://builds.sr.ht"} + for e in repo.config: + # breaking change in pygit2 0.26.4 + if isinstance(e, str): + (key, value) = (e, repo.config[e]) + else: + (key, value) = (e.name, e.value) + + if key == "sr.ht.token": + config["token"] = value + elif key == "sr.ht.url": + config["url"] = value + elif key == "sr.ht.user": + config["user"] = value + + missing = set() + if not "token" in config: + missing.add("token") + if not "user" in config: + missing.add("user") + if missing: + missing = ["sr.ht." + x for x in missing] + print("Missing configuration values: {}".format(", ".join(missing))) + return None + return config + + +def get_commit(repo, obj): + """ + Return the commit in *repo* that *obj* points to or *None* if *obj* is + neither a commit nor a tag. + """ + if isinstance(obj, pygit2.Commit): + return obj + elif isinstance(obj, pygit2.Tag): + return repo.get(obj.target) + else: + return None + + +def get_commit_summary(commit): + """ + Return the summary (i. e. first line) for the *commit*. + """ + return commit.message.split("\n", 1)[0] + + +def get_repo_name(): + """ + Return the name of the Git repository we are currently working on, based on + the name of the current working directory. + """ + name = os.path.basename(os.getcwd()) + if name.endswith(".git"): + name = name[:-4] + return name + + +def parse_refs(repo, refs): + """ + Parse the given *refs* as revisions in *repo* and return a tuple containing + a set of commits described by *refs* (with unique IDs) and a dictionary of + refs by commit ID. + """ + commits = set() + commit_ids = set() + refs_by_commit = {} + for ref in refs: + try: + obj = repo.revparse_single(ref) + except KeyError: + continue + commit = get_commit(repo, obj) + if not commit: + continue + if commit.id not in commit_ids: + refs_by_commit[commit.id] = set() + commit_ids.add(commit.id) + commits.add(commit) + if ref.startswith("refs/"): + ref = repo.lookup_reference(ref).shorthand + else: + ref = ref[7:] + refs_by_commit[commit.id].add(ref) + return (commits, refs_by_commit) + + +def find_manifests(repo, tree): + """ + Return a dictionary of build manifests in the *tree* in *repo* by file + name. This function checks the `.build.yml` file and the `.builds` + directory. + """ + manifests = {} + if '.builds' in tree: + entry = tree['.builds'] + if entry.type == 'tree': + subtree = repo[entry.id] + for subentry in subtree: + if subentry.type == 'blob': + name = subentry.name + manifests[name] = subentry.id + if '.build.yml' in tree: + entry = tree['.build.yml'] + if entry.type == 'blob': + manifests[entry.name] = entry.id + + for name in manifests: + manifests[name] = repo[manifests[name]].data.decode() + return manifests + + +def prepare_manifest(repo, text, commit, refs): + """ + Prepare the manifest *text* for submission. Currently, this only appends + `#` to the first source. Then it calls customize_manifest to allow + user customization. + """ + data = yaml.safe_load(text) + if "sources" in data: + data["sources"][0] += "#" + str(commit.id) + return customize_manifest(repo, data, commit, refs) + + +def build_note(commit, refs): + """ + Return the note to use when submitting a build. + """ + return "{}\n\n{} – [{}](mailto:{}) – {} ".format( + html.escape(get_commit_summary(commit)), + str(commit.id)[:7], + commit.author.name, + commit.author.email, + ", ".join(refs)) + + +def submit_build(config, manifest, name, commit, refs): + """ + Send an API query to builds.sr.ht to submit the a build *manifest* and + return the response data or *None* if the query failed. + """ + data = {} + data["manifest"] = yaml.dump(manifest, default_flow_style=False) + data["note"] = build_note(commit, refs) + data["tags"] = [get_repo_name()] + [name] if name else [] + headers = {"Authorization": "token {}".format(config["token"])} + result = requests.post("{}/api/jobs".format(config["url"]), json=data, headers=headers) + if result.status_code != 200: + print("API request failed with status code {}".format(request.status_code)) + return None + result = result.json() + if "errors" in result: + print("API error: {}".format(result["errors"])) + return None + return result + + +def trigger_builds(config, repo, commit, refs): + """ + Check the *commit* (that has been infered from *refs*) for build manifests + and trigger corresponding builds on builds.sr.ht. + """ + manifests = find_manifests(repo, commit.tree) + for name in manifests: + manifest = prepare_manifest(repo, manifests[name], commit, refs) + result = submit_build(config, manifest, name, commit, refs) + suffix = "{} ".format(name) if name != ".build.yml" else "" + suffix += "for {}".format(", ".join(refs)) + if result: + job_id = result["id"] + print("Build started: {}/~{}/job/{}/ {}".format(config["url"], config["user"], job_id, suffix)) + else: + print("Could not start build job {}".format(suffix)) + + +def exec_hook(): + """ + Execute the Git post-update hook that triggers builds on builds.sr.ht for + all pushed references. + """ + if len(sys.argv) < 2: + return + refs = sys.argv[1:] + + repo = pygit2.Repository('.') + config = get_config(repo) + if not config: + return + + (commits, refs) = parse_refs(repo, refs) + for commit in commits: + trigger_builds(config, repo, commit, refs[commit.id]) + + +if __name__ == '__main__': + exec_hook() -- cgit v1.2.1