aboutsummaryrefslogtreecommitdiff
path: root/post-update
diff options
context:
space:
mode:
Diffstat (limited to 'post-update')
-rwxr-xr-xpost-update274
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()