Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/druks/contrib/software_factory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,13 +515,15 @@ 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,
"title": self.title,
"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,
Expand Down
2 changes: 2 additions & 0 deletions backend/druks/contrib/software_factory/subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions backend/druks/contrib/software_factory/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
59 changes: 40 additions & 19 deletions backend/druks/core/webhooks/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"],
Expand All @@ -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")

Expand Down
88 changes: 64 additions & 24 deletions backend/druks/core/webhooks/linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"),
Expand All @@ -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})
8 changes: 6 additions & 2 deletions backend/tests/software_factory/test_board_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions backend/tests/software_factory/test_build_attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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": [],
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
Loading
Loading