webhooks: update todo tickets with git commits

Allow referencing tracker tickets in git commit messages via specific
trailer words:

  Fixes: <ticket url>
  Implements: <ticket url>
  References: <ticket url>

These must follow standard git trailer syntax. The trailers are
extracted from commit messages with the function added in previous
commit. The ticket url must point to a valid ticket.

A comment will be inserted in the ticket with a back reference to the
git commit and its original author. The comment will be made by the user
who pushed the commit. E.g.:

  ~arkanoid REPORTED -> FIXED                          9 seconds ago

  John Doe referenced this ticket in commit b4dc4c40.

Open tickets referenced by a Fixes trailer will be resolved with the
FIXED resolution.

Open tickets referenced by an Implements trailer will be resolved with
the IMPLEMENTED resolution.

Caveats:

* Only the 25 most recent commit messages will be considered when
  pushing long series. This should be a fairly sane limitation.

* If the user pushing commits does not have triage/comment permissions
  on the bug tracker, nothing will happen.

* Invalid/non-existent ticket urls are ignored.

* When a git repository is part of more than one project, the webhook
  will run once per project and update the same ticket(s) once per
  project as well.

* If an already resolved ticket is referenced by a Fixes or Implements
  trailer, only a comment will be added.

Link: https://git-scm.com/docs/git-interpret-trailers
Implements: https://todo.sr.ht/~sircmpwn/hub.sr.ht/55
Signed-off-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Robin Jarry 2021-11-28 21:07:37 +01:00 committed by Drew DeVault
parent a9f72b5a59
commit be5d50d8d8
2 changed files with 96 additions and 1 deletions

View File

@ -1,10 +1,12 @@
import email
import html
import json
import re
from datetime import datetime
from flask import Blueprint, request, current_app
from hubsrht.builds import submit_patchset
from hubsrht.services import todo, lists
from hubsrht.services import git, todo, lists
from hubsrht.trailers import commit_trailers
from hubsrht.types import Event, EventType, MailingList, SourceRepo, RepoType
from hubsrht.types import Tracker, User, Visibility
from srht.config import get_origin
@ -99,10 +101,64 @@ def git_repo(repo_id):
repo.project.updated = datetime.utcnow()
db.session.add(event)
db.session.commit()
for ref in payload["refs"]:
old = (ref["old"] or {}).get("id")
new = (ref["new"] or {}).get("id")
for commit in reversed(git.log(pusher, repo, old, new)):
for trailer, value in commit_trailers(commit["message"]):
_handle_commit_trailer(trailer, value, pusher, repo, commit)
return "Thanks!"
else:
raise NotImplementedError()
_ticket_url_re = re.compile(
rf"""
^
{re.escape(_todosrht)}
/(?P<owner>~[a-z_][a-z0-9_-]+)
/(?P<tracker>[\w.-]+)
/(?P<ticket>\d+)
$
""",
re.VERBOSE,
)
def _handle_commit_trailer(trailer, value, pusher, repo, commit):
if trailer == "Fixes":
resolution = "fixed"
elif trailer == "Implements":
resolution = "implemented"
elif trailer == "References":
resolution = None
else:
return
match = _ticket_url_re.match(value.strip())
if not match:
return
commit_message = html.escape(commit["message"].split("\n")[0])
commit_author = html.escape(commit["author"]["name"].strip())
commit_sha = commit["id"][:7]
commit_url = repo.url() + f"/commit/{commit_sha}"
comment = (
f"<i>{commit_author} referenced this ticket in commit " +
f"<a href='{commit_url}' title='{commit_message}'>{commit_sha}</a>.</i>")
try:
todo.update_ticket(
user=pusher,
owner=match["owner"],
tracker=match["tracker"],
ticket=int(match["ticket"]),
comment=" ".join(comment.split()).strip(),
resolution=resolution,
)
except Exception:
# invalid ticket or pusher does not have triage access, ignore
pass
@csrf_bypass
@webhooks.route("/webhooks/hg-user/<int:user_id>", methods=["POST"])
def hg_user(user_id):

View File

@ -179,6 +179,37 @@ class GitService(SrhtService):
return None
return manifests
def log(self, user, repo, old, new):
query = """
query Log($owner: String!, $repo: String!, $from: String!) {
repositoryByOwner(owner: $owner, repo: $repo) {
log(from: $from) {
results {
id
message
author {
name
}
}
}
}
}
"""
r = self.post(user, None, f"{_gitsrht}/query", {
"query": query,
"variables": {
"owner": repo.owner.canonical_name,
"repo": repo.name,
"from": new,
}
})
commits = []
for c in r["data"]["repositoryByOwner"]["log"]["results"]:
if c["id"] == old:
break
commits.append(c)
return commits
def create_repo(self, user, valid, visibility):
query = """
mutation CreateRepo(
@ -452,6 +483,14 @@ class TodoService(SrhtService):
except:
pass # nbd, upstream was presumably deleted
def update_ticket(self, user, owner, tracker, ticket, comment, resolution=None):
url = f"{_todosrht}/api/user/{owner}/trackers/{tracker}/tickets/{ticket}"
payload = {"comment": comment}
if resolution is not None:
payload["resolution"] = resolution
payload["status"] = "resolved"
self.put(user, None, url, payload)
class BuildService(SrhtService):
def submit_build(self, user, manifest, note, tags, execute=True):
return self.post(user, None, f"{_buildsrht}/api/jobs", {