From 7571561349522549726f1ae929613661d95df563 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Wed, 4 Nov 2026 00:57:34 +0100 Subject: [PATCH] DRU-958 -- Publish Linear and GitHub issues as tracker events --- .../druks/contrib/software_factory/models.py | 2 + .../contrib/software_factory/subscribers.py | 2 + .../contrib/software_factory/webhooks.py | 2 + backend/druks/core/webhooks/github.py | 59 ++++++--- backend/druks/core/webhooks/linear.py | 88 +++++++++---- .../software_factory/test_board_routes.py | 8 +- .../test_build_attribution.py | 5 + .../test_webhooks_github_issues.py | 5 + .../software_factory/test_webhooks_jira.py | 4 + backend/tests/test_github_channel.py | 13 +- backend/tests/test_managed_linear.py | 2 + backend/tests/test_webhooks_github.py | 94 ++++++++----- backend/tests/test_webhooks_linear.py | 124 ++++++++++++++++++ docs/writing-an-app.md | 52 +++++++- 14 files changed, 377 insertions(+), 83 deletions(-) diff --git a/backend/druks/contrib/software_factory/models.py b/backend/druks/contrib/software_factory/models.py index 77b178cea..240e88987 100644 --- a/backend/druks/contrib/software_factory/models.py +++ b/backend/druks/contrib/software_factory/models.py @@ -515,6 +515,7 @@ async def _emit_transitioned(self, status: Status) -> None: "ticket.transitioned", payload={ "source": "druks", + "ticket_id": str(self.id), "identifier": self.identifier, # The display label, the way Linear and Jira publish state names. "status": status.label, @@ -522,6 +523,7 @@ async def _emit_transitioned(self, status: Status) -> None: "url": f"/software_factory/tickets/{self.identifier}", "project_name": self.project_repo.full_name, "labels": [], + "team": None, "actor_id": None, "assignee_id": self.assignee_id, "assignee_email": assignee.username if assignee else None, diff --git a/backend/druks/contrib/software_factory/subscribers.py b/backend/druks/contrib/software_factory/subscribers.py index a588a7397..78f3d4b77 100644 --- a/backend/druks/contrib/software_factory/subscribers.py +++ b/backend/druks/contrib/software_factory/subscribers.py @@ -88,6 +88,7 @@ async def issue_label_transitions_the_ticket(*, repo: str, number: int, payload: "ticket.transitioned", payload={ "source": "github", + "ticket_id": str(payload["issue_id"]), # Issue numbers repeat across repos, so the key carries the repo. "identifier": f"{repo}#{number}", "status": payload["label"], @@ -96,6 +97,7 @@ async def issue_label_transitions_the_ticket(*, repo: str, number: int, payload: # The issue's own repo is the build's repo, so no label routes it elsewhere. "project_name": repo, "labels": [], + "team": None, "actor_id": str(payload["actor_id"]), "assignee_id": str(assignee_id) if assignee_id else None, "assignee_email": None, diff --git a/backend/druks/contrib/software_factory/webhooks.py b/backend/druks/contrib/software_factory/webhooks.py index a8c103eea..fd2a83035 100644 --- a/backend/druks/contrib/software_factory/webhooks.py +++ b/backend/druks/contrib/software_factory/webhooks.py @@ -62,12 +62,14 @@ async def on_issue_event(self) -> Response: "ticket.transitioned", payload={ "source": "jira", + "ticket_id": issue["id"], "identifier": key, "status": issue_status["name"], "title": fields["summary"], "url": f"{base_url.rstrip('/')}/browse/{key}", "project_name": fields["project"]["name"], "labels": fields["labels"], + "team": None, "actor_id": self.request.headers.get("x-jira-initiator"), "assignee_id": assignee.get("accountId"), "assignee_email": assignee.get("emailAddress"), diff --git a/backend/druks/core/webhooks/github.py b/backend/druks/core/webhooks/github.py index 58be82434..8f3164079 100644 --- a/backend/druks/core/webhooks/github.py +++ b/backend/druks/core/webhooks/github.py @@ -76,7 +76,20 @@ async def on_pull_request_review_submitted(self) -> Response: async def on_issue_comment_created(self) -> Response: # GitHub files pull-request comments under issues; only those carry ``pull_request``. issue = self.data["issue"] - await self._publish_comment(issue["number"], is_pull_request="pull_request" in issue) + is_pull_request = "pull_request" in issue + await self._publish_comment(issue["number"], is_pull_request=is_pull_request) + if not is_pull_request: + # Any sender's comment is part of the ticket, an app's included. + comment = self.data["comment"] + await publish( + "ticket.commented", + payload={ + **self._ticket(issue), + "comment_id": comment["id"], + # GitHub comments are flat. + "parent_id": None, + }, + ) return _accepted() async def on_pull_request_review_comment_created(self) -> Response: @@ -123,6 +136,7 @@ async def on_issues_labeled(self) -> Response: repo=_repo_name(self.data), number=issue["number"], payload={ + "issue_id": issue["id"], "label": self.data["label"]["name"], "actor_id": sender["id"], "title": issue["title"], @@ -133,33 +147,40 @@ async def on_issues_labeled(self) -> Response: ) return _accepted() + # GitHub sends a pull request's own changes as ``pull_request`` events, so these are + # issues only. async def on_issues_opened(self) -> Response: - return await self._publish_issue("issue.opened") + return await self._publish_ticket("ticket.created") async def on_issues_edited(self) -> Response: - return await self._publish_issue("issue.edited") + return await self._publish_ticket("ticket.updated") async def on_issues_reopened(self) -> Response: - return await self._publish_issue("issue.reopened") + return await self._publish_ticket("ticket.updated") - async def _publish_issue(self, fact: str) -> Response: - # GitHub sends a pull request's own changes as ``pull_request`` events, so these - # are issues only. + async def _publish_ticket(self, fact: str) -> Response: issue = self.data["issue"] - await publish( - fact, - repo=_repo_name(self.data), - number=issue["number"], - payload={ - "title": issue["title"], - "body": issue["body"] or "", - "url": issue["html_url"], - "author": issue["user"]["login"], - "author_can_write": issue["author_association"] in _WRITERS, - }, - ) + await publish(fact, payload=self._ticket(issue)) return _accepted() + def _ticket(self, issue: dict[str, Any]) -> dict[str, Any]: + """The keys every ``ticket.*`` event carries, for one issue.""" + repo = _repo_name(self.data) + return { + "source": "github", + # The identifier changes when the repo is renamed; the id does not. + "ticket_id": str(issue["id"]), + # Issue numbers repeat across repos, so the key carries the repo. + "identifier": f"{repo}#{issue['number']}", + "title": issue["title"], + "url": issue["html_url"], + # The issue's repo is where it belongs, as ``ticket.transitioned`` has it. + "project_name": repo, + "labels": [label["name"] for label in issue["labels"]], + "team": None, + "actor_id": str(self.data["sender"]["id"]), + } + async def on_pull_request_opened(self) -> Response: return await self._publish_pull_request("pr.opened") diff --git a/backend/druks/core/webhooks/linear.py b/backend/druks/core/webhooks/linear.py index 20b5a3fae..541410528 100644 --- a/backend/druks/core/webhooks/linear.py +++ b/backend/druks/core/webhooks/linear.py @@ -7,6 +7,8 @@ from druks.signals import publish from druks.webhooks import Webhook, verify_hmac_sha256 +_CONTENT_FIELDS = frozenset({"title", "description", "labelIds", "projectId"}) + class LinearEvents(Webhook): provider = "linear" @@ -33,31 +35,37 @@ def get_delivery_key(self) -> str: return compute_delivery_key(headers, self.raw_body, self.data) def get_action(self) -> str: - action = self.data.get("action") - if self.data.get("type") == "Comment" and action == "create": + action, kind = self.data.get("action"), self.data.get("type") + updated_from = self.data.get("updatedFrom") or {} + if kind == "Comment" and action == "create": return "comment_created" - if action == "update" and "stateId" in (self.data.get("updatedFrom") or {}): + if kind == "Issue" and action == "create": + return "issue_created" + if action == "update" and "stateId" in updated_from: return "state_transition" + # Linear sends an update for every change, a drag on the board included. Only a + # change to what the issue says or where it belongs publishes ``ticket.updated``. + if kind == "Issue" and action == "update" and _CONTENT_FIELDS & updated_from.keys(): + return "issue_updated" return "ticket_change" + async def on_issue_created(self) -> Response: + await publish("ticket.created", payload=self._ticket()) + return _accepted() + + async def on_issue_updated(self) -> Response: + await publish("ticket.updated", payload=self._ticket()) + return _accepted() + async def on_state_transition(self) -> Response: issue = self.data["data"] - state = issue["state"] - # An issue outside a project has no project. An unassigned issue has no assignee. - project = issue.get("project") or {} + # An unassigned issue has no assignee. assignee = issue.get("assignee") or {} - actor = self.data.get("actor") or {} await publish( "ticket.transitioned", payload={ - "source": "linear", - "identifier": issue["identifier"], - "status": state["name"], - "title": issue["title"], - "url": issue["url"], - "project_name": project.get("name"), - "labels": [label["name"] for label in issue["labels"]], - "actor_id": actor.get("id"), + **self._ticket(), + "status": issue["state"]["name"], "assignee_id": assignee.get("id"), "assignee_email": assignee.get("email"), "assignee_name": assignee.get("name"), @@ -67,17 +75,49 @@ async def on_state_transition(self) -> Response: async def on_comment_created(self) -> Response: comment = self.data["data"] - await publish( - "ticket.commented", - payload={ - "source": "linear", - # Top-level comments have no parent. - "parent_id": comment.get("parentId"), - "issue_id": comment["issueId"], - }, - ) + # A comment on a project or a document belongs to no ticket. + if issue := comment.get("issue"): + await publish( + "ticket.commented", + payload={ + "source": "linear", + "ticket_id": issue["id"], + "identifier": issue["identifier"], + "title": issue["title"], + "url": issue["url"], + # A comment's issue carries neither its project nor its labels. + "project_name": None, + "labels": None, + "team": issue["teamId"], + "actor_id": self._actor_id(), + "comment_id": comment["id"], + # Top-level comments have no parent. + "parent_id": comment.get("parentId"), + }, + ) return _accepted() + def _ticket(self) -> dict: + """The keys every ``ticket.*`` event carries, for the issue in this delivery.""" + issue = self.data["data"] + # An issue outside a project has no project. + project = issue.get("project") or {} + return { + "source": "linear", + # The identifier changes when the issue moves to another team; the id never does. + "ticket_id": issue["id"], + "identifier": issue["identifier"], + "title": issue["title"], + "url": issue["url"], + "project_name": project.get("name"), + "labels": [label["name"] for label in issue["labels"]], + "team": issue["teamId"], + "actor_id": self._actor_id(), + } + + def _actor_id(self) -> str | None: + return (self.data.get("actor") or {}).get("id") + def _accepted() -> Response: return JSONResponse({"accepted": True}) diff --git a/backend/tests/software_factory/test_board_routes.py b/backend/tests/software_factory/test_board_routes.py index 487955185..6babf80ea 100644 --- a/backend/tests/software_factory/test_board_routes.py +++ b/backend/tests/software_factory/test_board_routes.py @@ -2,6 +2,7 @@ from druks.accounts.models import Account from druks.contrib.software_factory.app import SoftwareFactory from druks.contrib.software_factory.enums import Status +from druks.contrib.software_factory.models import Ticket _TICKETS = "/api/software_factory/tickets" _PROJECTS = "/api/software_factory/projects" @@ -23,17 +24,19 @@ async def emit(name, **kwargs): return events -def _ready(identifier, title, repo): +def _ready(ticket_id, identifier, title, repo): return ( "ticket.transitioned", { "source": "druks", + "ticket_id": str(ticket_id), "identifier": identifier, "status": Status.READY_FOR_AGENT.label, "title": title, "url": f"/software_factory/tickets/{identifier}", "project_name": repo, "labels": [], + "team": None, "actor_id": None, "assignee_id": None, "assignee_email": None, @@ -90,7 +93,8 @@ async def test_ready_for_agent_publishes_one_transition(druks_client, monkeypatc assert moved.status_code == 200 assert moved.json()["status"] == "ready_for_agent" - assert events == [_ready("ACM-1", "Add an endpoint", "acme/acme-app")] + stored = await Ticket.get_for_identifier("ACM-1") + assert events == [_ready(stored.id, "ACM-1", "Add an endpoint", "acme/acme-app")] async def test_an_edit_takes_what_it_is_given_and_leaves_the_rest( diff --git a/backend/tests/software_factory/test_build_attribution.py b/backend/tests/software_factory/test_build_attribution.py index 4b664e978..496dd457e 100644 --- a/backend/tests/software_factory/test_build_attribution.py +++ b/backend/tests/software_factory/test_build_attribution.py @@ -86,6 +86,7 @@ async def get_settings(cls): ) events._data_cached = { "issue": { + "id": "10042", "key": item.ticket_key, "fields": { "status": {"name": settings.trigger_status}, @@ -110,9 +111,11 @@ async def get_settings(cls): events._data_cached = { "actor": {"id": "provider-actor-1"}, "data": { + "id": "issue-uuid-1", "identifier": item.ticket_key, "title": item.title, "url": f"https://linear.app/company/issue/{item.ticket_key}", + "teamId": "team-1", "state": {"name": settings.trigger_status, "type": "unstarted"}, "project": {"name": "company/app"}, "labels": [], @@ -141,12 +144,14 @@ async def test_unconnected_actor_and_assignee_use_the_default_account(druks_db, await Build.dispatch( ticket={ "source": item.source, + "ticket_id": "issue-uuid-1", "identifier": item.ticket_key, "status": "Ready", "title": item.title, "url": f"https://tracker.example/{item.ticket_key}", "project_name": "company/app", "labels": [], + "team": "team-1", "actor_id": "unconnected-actor", "assignee_id": "provider-user-1", "assignee_email": "boss@company.test", diff --git a/backend/tests/software_factory/test_webhooks_github_issues.py b/backend/tests/software_factory/test_webhooks_github_issues.py index d27b974ee..514da94e1 100644 --- a/backend/tests/software_factory/test_webhooks_github_issues.py +++ b/backend/tests/software_factory/test_webhooks_github_issues.py @@ -24,6 +24,7 @@ def _labeled(*, assignee=None, sender_type="User", sender_login="octocat"): "action": "labeled", "label": {"name": "ready-for-agent"}, "issue": { + "id": 4242, "number": 7, "title": "Contacts import drops the last row", "html_url": "https://github.com/acme/widget/issues/7", @@ -66,6 +67,7 @@ async def test_a_persons_label_publishes_the_label_and_who_put_it(druks_db, tmp_ "repo": "acme/widget", "number": 7, "payload": { + "issue_id": 4242, "label": "ready-for-agent", "actor_id": 583231, "title": "Contacts import drops the last row", @@ -113,6 +115,7 @@ async def _settings(cls): repo="acme/widget", number=7, payload={ + "issue_id": 4242, "label": "ready-for-agent", "actor_id": 583231, "title": "Contacts import drops the last row", @@ -125,12 +128,14 @@ async def _settings(cls): build.assert_awaited_once_with( ticket={ "source": "github", + "ticket_id": "4242", "identifier": "acme/widget#7", "status": "ready-for-agent", "title": "Contacts import drops the last row", "url": "https://github.com/acme/widget/issues/7", "project_name": "acme/widget", "labels": [], + "team": None, "actor_id": "583231", "assignee_id": None, "assignee_email": None, diff --git a/backend/tests/software_factory/test_webhooks_jira.py b/backend/tests/software_factory/test_webhooks_jira.py index d497d9f3f..bbaef0a89 100644 --- a/backend/tests/software_factory/test_webhooks_jira.py +++ b/backend/tests/software_factory/test_webhooks_jira.py @@ -28,6 +28,7 @@ def _provider(tmp_path, *, payload, headers=None): def _issue(*, key="IT-12", status="Open", project="acme-app", labels=()): return { "issue": { + "id": "10042", "key": key, "fields": { "status": {"name": status}, @@ -43,12 +44,14 @@ def _issue(*, key="IT-12", status="Open", project="acme-app", labels=()): def _jira_payload(*, key="IT-12", status="Open", project="acme-app", labels=None): return { "source": "jira", + "ticket_id": "10042", "identifier": key, "status": status, "title": "Add an endpoint", "url": None, "project_name": project, "labels": labels or [], + "team": None, "actor_id": None, "assignee_id": None, "assignee_email": "dev@acme.co", @@ -120,6 +123,7 @@ async def _emit(event_type, **kwargs): assert captured["event"] == "ticket.transitioned" payload = captured["payload"] assert payload["source"] == "jira" + assert payload["ticket_id"] == "10042" assert payload["identifier"] == "IT-9" assert payload["status"] == "Ready" assert payload["actor_id"] == "actor-1" diff --git a/backend/tests/test_github_channel.py b/backend/tests/test_github_channel.py index 57fd4265b..58d906054 100644 --- a/backend/tests/test_github_channel.py +++ b/backend/tests/test_github_channel.py @@ -108,7 +108,14 @@ def comment_event( comment["in_reply_to_id"] = in_reply_to_id return { "repository": {"full_name": "acme/app"}, - "issue": {"number": 7, **({"pull_request": {}} if is_pull_request else {})}, + "issue": { + "id": 4242, + "number": 7, + "title": "Import contacts", + "html_url": "https://github.com/acme/app/issues/7", + "labels": [], + **({"pull_request": {}} if is_pull_request else {}), + }, "pull_request": {"number": 7}, "sender": {"type": sender_type, "login": login, "id": user}, "comment": comment, @@ -128,7 +135,9 @@ async def test_the_webhook_publishes_every_persons_comment_as_one_fact(tmp_path, published = [] async def publish(name, **kwargs): - published.append((name, kwargs)) + # An issue comment is also a tracker fact; this test is about the chat one. + if name != "ticket.commented": + published.append((name, kwargs)) monkeypatch.setattr(github_webhooks, "publish", publish) diff --git a/backend/tests/test_managed_linear.py b/backend/tests/test_managed_linear.py index bd7119ebd..6559df70b 100644 --- a/backend/tests/test_managed_linear.py +++ b/backend/tests/test_managed_linear.py @@ -187,9 +187,11 @@ def _ready_for_agent() -> bytes: "updatedFrom": {"stateId": "backlog"}, "actor": {"id": "user-1", "type": "user"}, "data": { + "id": "issue-1", "identifier": "ACME-1", "title": "Ship it", "url": "https://linear.app/acme/issue/ACME-1", + "teamId": "team-1", "state": {"name": "Ready for Agent"}, "labels": [], }, diff --git a/backend/tests/test_webhooks_github.py b/backend/tests/test_webhooks_github.py index 65bb9f7a1..1a59c1337 100644 --- a/backend/tests/test_webhooks_github.py +++ b/backend/tests/test_webhooks_github.py @@ -48,58 +48,88 @@ async def _emit(event_type, **kwargs): return published -def _issue(*, action, body="It drops the labels."): +def _issue(*, action, is_pull_request=False, sender_type="User"): + issue = { + "id": 4242, + "number": 7, + "title": "The webhook drops labels", + "html_url": "https://github.com/acme/widget/issues/7", + "labels": [{"id": 1, "name": "bug"}], + "user": {"login": "reporter"}, + } + if is_pull_request: + issue["pull_request"] = {} return { "action": action, "repository": {"full_name": "acme/widget"}, - "issue": { - "number": 7, - "title": "The webhook drops labels", - "body": body, - "html_url": "https://github.com/acme/widget/issues/7", - "user": {"login": "passerby"}, - "author_association": "NONE", - }, + "issue": issue, + "sender": {"type": sender_type, "login": "passerby", "id": 99}, + "comment": {"id": 501, "body": "Still broken", "author_association": "NONE"}, } +TICKET = { + "source": "github", + "ticket_id": "4242", + "identifier": "acme/widget#7", + "title": "The webhook drops labels", + "url": "https://github.com/acme/widget/issues/7", + "project_name": "acme/widget", + "labels": ["bug"], + "team": None, + # Whoever acted, not the issue's author. + "actor_id": "99", +} + + @pytest.mark.parametrize( ("action", "handler", "fact"), [ - ("opened", "on_issues_opened", "issue.opened"), - ("edited", "on_issues_edited", "issue.edited"), - ("reopened", "on_issues_reopened", "issue.reopened"), + ("opened", "on_issues_opened", "ticket.created"), + ("edited", "on_issues_edited", "ticket.updated"), + ("reopened", "on_issues_reopened", "ticket.updated"), ], ) -async def test_an_issue_action_publishes_its_fact(tmp_path, monkeypatch, action, handler, fact): +async def test_an_issue_action_publishes_a_tracker_fact( + tmp_path, monkeypatch, action, handler, fact +): published = _capture(monkeypatch) await getattr(_events(tmp_path, payload=_issue(action=action)), handler)() - assert published == [ - ( - fact, - { - "repo": "acme/widget", - "number": 7, - "payload": { - "title": "The webhook drops labels", - "body": "It drops the labels.", - "url": "https://github.com/acme/widget/issues/7", - "author": "passerby", - "author_can_write": False, - }, - }, - ) - ] + assert published == [(fact, {"payload": TICKET})] + + +async def test_a_persons_issue_comment_publishes_the_chat_and_the_tracker_facts( + tmp_path, monkeypatch +): + published = _capture(monkeypatch) + + await _events(tmp_path, payload=_issue(action="created")).on_issue_comment_created() + + assert [name for name, _ in published] == ["issue.commented", "ticket.commented"] + assert published[1] == ( + "ticket.commented", + {"payload": TICKET | {"comment_id": 501, "parent_id": None}}, + ) + + +async def test_a_pull_request_comment_is_no_tracker_fact(tmp_path, monkeypatch): + published = _capture(monkeypatch) + payload = _issue(action="created", is_pull_request=True) + + await _events(tmp_path, payload=payload).on_issue_comment_created() + + assert [name for name, _ in published] == ["issue.commented"] -async def test_an_issue_without_a_body_publishes_an_empty_one(tmp_path, monkeypatch): +async def test_an_apps_issue_comment_is_a_tracker_fact_only(tmp_path, monkeypatch): published = _capture(monkeypatch) + payload = _issue(action="created", sender_type="Bot") - await _events(tmp_path, payload=_issue(action="opened", body=None)).on_issues_opened() + await _events(tmp_path, payload=payload).on_issue_comment_created() - assert published[0][1]["payload"]["body"] == "" + assert [name for name, _ in published] == ["ticket.commented"] @pytest.mark.parametrize( diff --git a/backend/tests/test_webhooks_linear.py b/backend/tests/test_webhooks_linear.py index 4731b0918..c13f7df37 100644 --- a/backend/tests/test_webhooks_linear.py +++ b/backend/tests/test_webhooks_linear.py @@ -30,9 +30,11 @@ def _transition(): "updatedFrom": {"stateId": "old-state"}, "actor": {"id": "actor-1", "type": "user"}, "data": { + "id": "issue-uuid-7", "identifier": "ACME-7", "title": "Add an endpoint", "url": "https://linear.app/acme/issue/ACME-7", + "teamId": "team-1", "state": {"name": "Done"}, "project": {"name": "acme-app"}, "labels": [{"id": "label-1", "name": "gadget", "color": "#5e6ad2"}], @@ -104,12 +106,14 @@ async def test_emits_normalized_ticket_transition(tmp_path, monkeypatch): "ticket.transitioned", { "source": "linear", + "ticket_id": "issue-uuid-7", "identifier": "ACME-7", "status": "Done", "title": "Add an endpoint", "url": "https://linear.app/acme/issue/ACME-7", "project_name": "acme-app", "labels": ["gadget"], + "team": "team-1", "actor_id": "actor-1", "assignee_id": "user-7", "assignee_email": "dev@acme.co", @@ -117,3 +121,123 @@ async def test_emits_normalized_ticket_transition(tmp_path, monkeypatch): }, ) ] + + +def _issue(*, action, updated_from=None, project=None): + return { + "action": action, + "type": "Issue", + "updatedFrom": updated_from, + "actor": {"id": "user-3", "type": "user"}, + "data": { + "id": "issue-uuid-7", + "identifier": "ACME-7", + "title": "Add an endpoint", + "url": "https://linear.app/acme/issue/ACME-7", + "teamId": "team-1", + "project": project, + "labels": [{"id": "label-1", "name": "gadget", "color": "#5e6ad2"}], + }, + } + + +def _ticket(*, project_name=None): + return { + "source": "linear", + "ticket_id": "issue-uuid-7", + "identifier": "ACME-7", + "title": "Add an endpoint", + "url": "https://linear.app/acme/issue/ACME-7", + "project_name": project_name, + "labels": ["gadget"], + "team": "team-1", + "actor_id": "user-3", + } + + +async def test_a_new_issue_publishes_ticket_created(tmp_path, monkeypatch): + events = _capture(monkeypatch) + payload = _issue(action="create", project={"id": "p-1", "name": "Intake", "url": "u"}) + provider = _provider(tmp_path, payload=payload) + + assert provider.get_action() == "issue_created" + await provider.on_issue_created() + + assert events == [("ticket.created", _ticket(project_name="Intake"))] + + +async def test_a_content_change_publishes_ticket_updated(tmp_path, monkeypatch): + events = _capture(monkeypatch) + provider = _provider(tmp_path, payload=_issue(action="update", updated_from={"title": "old"})) + + assert provider.get_action() == "issue_updated" + await provider.on_issue_updated() + + assert events == [("ticket.updated", _ticket())] + + +@pytest.mark.parametrize("updated_from", [{"sortOrder": 1.5}, {"priority": 2}, None]) +def test_an_update_that_changes_no_content_is_not_a_ticket_update(tmp_path, updated_from): + provider = _provider(tmp_path, payload=_issue(action="update", updated_from=updated_from)) + + assert provider.get_action() == "ticket_change" + + +def test_a_state_change_stays_a_transition(tmp_path): + payload = _issue(action="update", updated_from={"stateId": "s-1", "title": "old"}) + + assert _provider(tmp_path, payload=payload).get_action() == "state_transition" + + +async def test_a_comment_publishes_ticket_commented(tmp_path, monkeypatch): + events = _capture(monkeypatch) + payload = { + "action": "create", + "type": "Comment", + "actor": {"id": "user-3", "type": "user"}, + "data": { + "id": "comment-1", + "issue": { + "id": "issue-uuid-7", + "identifier": "ACME-7", + "title": "Add an endpoint", + "url": "https://linear.app/acme/issue/ACME-7", + "teamId": "team-1", + }, + "parentId": None, + }, + } + + await _provider(tmp_path, payload=payload).on_comment_created() + + assert events == [ + ( + "ticket.commented", + { + "source": "linear", + "ticket_id": "issue-uuid-7", + "identifier": "ACME-7", + "title": "Add an endpoint", + "url": "https://linear.app/acme/issue/ACME-7", + "project_name": None, + "labels": None, + "team": "team-1", + "actor_id": "user-3", + "comment_id": "comment-1", + "parent_id": None, + }, + ) + ] + + +async def test_a_comment_on_no_issue_is_no_ticket_comment(tmp_path, monkeypatch): + events = _capture(monkeypatch) + payload = { + "action": "create", + "type": "Comment", + "data": {"id": "comment-1", "projectUpdateId": "update-1", "body": "Hi"}, + } + + await _provider(tmp_path, payload=payload).on_comment_created() + + assert events == [] diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index 8470c1410..b6f014806 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -1366,10 +1366,11 @@ Each carries `repo`, `pr_number`, and a `payload` with `branch`, `is_fork`, `author_can_write`. A fork can use any branch name, so a subscriber that matches on `branch` filters with `payload__is_fork=False`. -It also publishes `issue.opened`, `issue.edited`, and `issue.reopened` for -issues, not pull requests. Each carries `repo`, `number`, and a `payload` with -`title`, `body`, `url`, `author`, and `author_can_write`. An edit carries the -issue as it is after the edit, not what changed. +A person's comment on an issue, a pull request, or a line of a diff publishes +`issue.commented` with `repo`, `number`, and a `payload` with `author`, +`author_id`, `author_can_write`, `body`, `comment_id`, `review_thread_id`, and +`is_pull_request`. The GitHub chat channel answers it. GitHub issues themselves +publish [tracker events](#tracker-events). `post_check_run` creates the named check run on the head commit, or updates the one the App already posted there. An annotation is GitHub's shape. The App needs @@ -1404,6 +1405,49 @@ async def check_the_head(*, repo: str, payload: dict, **_: object) -> None: ) ``` +### Tracker events + +Linear and GitHub tickets publish the same events, and each `payload` names its +tracker in `source`. Filter on it: + +```python +from druks.signals import subscribe + + +@subscribe("ticket.created", payload__source="linear") +async def on_new_issue(*, payload: dict, **_: object) -> None: + ... +``` + +| Event | Linear | GitHub | +| --- | --- | --- | +| `ticket.created` | an issue is created | an issue is opened | +| `ticket.updated` | an issue's title, description, labels, or project changes | an issue is edited or reopened | +| `ticket.commented` | a comment on an issue is created | a comment on an issue, from any sender | +| `ticket.transitioned` | an issue's status changes | a label is added, through Software Factory's GitHub tracker | + +Every `ticket.*` event carries the same ticket keys, whatever the tracker: +`source`, `ticket_id`, `identifier`, `title`, `url`, `project_name`, `labels`, +`team`, and `actor_id`. A key the tracker sends no value for is `null`. +`identifier` is the ticket's name: `DRU-123` in Linear, `owner/repo#number` on +GitHub. It changes when a Linear issue moves to another team or a GitHub +repository is renamed, so key stored state on `ticket_id`, the tracker's own id. +`actor_id` is whoever made the change. A payload carries the ticket as it is +after the change, not what changed; read the ticket for its description or body. + +| Event | Adds | +| --- | --- | +| `ticket.created`, `ticket.updated` | nothing | +| `ticket.commented` | `comment_id`, `parent_id` | +| `ticket.transitioned` | `status`, `assignee_id`, `assignee_email`, `assignee_name` | + +On GitHub, `project_name` is the repository, and `team` and `parent_id` are +`null`. In Linear, `team` is the team's id. A Linear comment's payload has no +project or labels, so `ticket.commented` from Linear carries `null` for both, +and a Linear comment on a project or a document publishes nothing. Jira and +Software Factory's own board publish `ticket.transitioned` too, with `team` +`null`. + ## Models and migrations Models subclass `druks.db.Model`. Druks names the table for the app and the