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 email
import html import html
import json import json
import re
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, current_app from flask import Blueprint, request, current_app
from hubsrht.builds import submit_patchset 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 Event, EventType, MailingList, SourceRepo, RepoType
from hubsrht.types import Tracker, User, Visibility from hubsrht.types import Tracker, User, Visibility
from srht.config import get_origin from srht.config import get_origin
@ -99,10 +101,64 @@ def git_repo(repo_id):
repo.project.updated = datetime.utcnow() repo.project.updated = datetime.utcnow()
db.session.add(event) db.session.add(event)
db.session.commit() 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!" return "Thanks!"
else: else:
raise NotImplementedError() 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 @csrf_bypass
@webhooks.route("/webhooks/hg-user/<int:user_id>", methods=["POST"]) @webhooks.route("/webhooks/hg-user/<int:user_id>", methods=["POST"])
def hg_user(user_id): def hg_user(user_id):

View File

@ -179,6 +179,37 @@ class GitService(SrhtService):
return None return None
return manifests 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): def create_repo(self, user, valid, visibility):
query = """ query = """
mutation CreateRepo( mutation CreateRepo(
@ -452,6 +483,14 @@ class TodoService(SrhtService):
except: except:
pass # nbd, upstream was presumably deleted 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): class BuildService(SrhtService):
def submit_build(self, user, manifest, note, tags, execute=True): def submit_build(self, user, manifest, note, tags, execute=True):
return self.post(user, None, f"{_buildsrht}/api/jobs", { return self.post(user, None, f"{_buildsrht}/api/jobs", {