diff options
| author | Robin Krahl <me@robin-krahl.de> | 2019-01-24 14:45:34 +0100 | 
|---|---|---|
| committer | Robin Krahl <me@robin-krahl.de> | 2019-01-24 14:45:34 +0100 | 
| commit | c25185e041b20a531d49ed97095aa2cb47e0a8eb (patch) | |
| tree | 421b6cd39da80eba211c18b4c7dee19f4f7b7ecb /post-update | |
| download | sr.ht-scripts-c25185e041b20a531d49ed97095aa2cb47e0a8eb.tar.gz sr.ht-scripts-c25185e041b20a531d49ed97095aa2cb47e0a8eb.tar.bz2  | |
Diffstat (limited to 'post-update')
| -rwxr-xr-x | post-update | 274 | 
1 files changed, 274 insertions, 0 deletions
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 <robin.krahl@ireas.org> +# 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 +    `#<commit>` 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()  | 
