Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/druks/contrib/software_factory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ async def _emit_transitioned(self, status: Status) -> None:
"url": f"/software_factory/tickets/{self.identifier}",
"project_name": self.project_repo.full_name,
"labels": [],
"actor_id": None,
"assignee_id": self.assignee_id,
"assignee_email": assignee.username if assignee else None,
"assignee_name": assignee.username if assignee else None,
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/contrib/software_factory/ticketing/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ class Tracker(ABC):
# The MCP grant issuer whose subjects are this tracker's user ids.
authority: str

async def get_account_id(self, user_id: str) -> str | None:
async def get_account_id(self, user_id: str | None) -> str | None:
"""The one Druks account that connected this tracker as the user."""
if account := await Account.lookup(db_session(), self.authority, user_id):
if user_id and (account := await Account.lookup(db_session(), self.authority, user_id)):
return account.id

async def __aenter__(self) -> Self:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async def set_status(self, key: str, status: TicketStatus) -> None:
return
raise UnknownTicketError(key, "druks")

async def get_account_id(self, user_id: str) -> str | None:
async def get_account_id(self, user_id: str | None) -> str | None:
# A board ticket's assignee is a Druks account.
return user_id

Expand Down
3 changes: 3 additions & 0 deletions backend/druks/contrib/software_factory/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ async def on_state_transition(self) -> Response:
# An issue outside a project has no project. An unassigned issue has no assignee.
project = issue.get("project") or {}
assignee = issue.get("assignee") or {}
actor = self.data.get("actor") or {}
await publish(
"ticket.transitioned",
payload={
Expand All @@ -62,6 +63,7 @@ async def on_state_transition(self) -> Response:
"url": issue["url"],
"project_name": project.get("name"),
"labels": [],
"actor_id": actor.get("id"),
"assignee_id": assignee.get("id"),
"assignee_email": assignee.get("email"),
"assignee_name": assignee.get("name"),
Expand Down Expand Up @@ -138,6 +140,7 @@ async def on_issue_event(self) -> Response:
"url": f"{base_url.rstrip('/')}/browse/{key}",
"project_name": fields["project"]["name"],
"labels": fields["labels"],
"actor_id": self.request.headers.get("x-jira-initiator"),
"assignee_id": assignee.get("accountId"),
"assignee_email": assignee.get("emailAddress"),
"assignee_name": assignee.get("displayName"),
Expand Down
5 changes: 3 additions & 2 deletions backend/druks/contrib/software_factory/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,10 @@ async def dispatch(cls, *, ticket: dict) -> str | None:
await item.announce("build.rejected", reason=str(error))
return
account_id = None
if ticket["assignee_id"] and (tracker := await SoftwareFactory.get_tracker()):
if tracker := await SoftwareFactory.get_tracker():
async with tracker:
account_id = await tracker.get_account_id(ticket["assignee_id"])
account_id = await tracker.get_account_id(ticket["actor_id"])
account_id = account_id or await tracker.get_account_id(ticket["assignee_id"])
return await cls.start(
subject=item,
account_id=account_id,
Expand Down
1 change: 1 addition & 0 deletions backend/tests/software_factory/test_board_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def _ready(identifier, title, repo):
"url": f"/software_factory/tickets/{identifier}",
"project_name": repo,
"labels": [],
"actor_id": None,
"assignee_id": None,
"assignee_email": None,
"assignee_name": None,
Expand Down
26 changes: 22 additions & 4 deletions backend/tests/software_factory/test_build_attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,22 @@ async def start(cls, **kwargs):


@pytest.mark.parametrize(("source", "tracker"), [("jira", Jira), ("linear", Linear)])
@pytest.mark.parametrize("actor_is_connected", [True, False])
async def test_tracker_webhook_starts_under_the_connected_account(
druks_db, tmp_path, monkeypatch, started, source, tracker
druks_db, tmp_path, monkeypatch, started, source, tracker, actor_is_connected
):
owner = await Account.get_or_create(druks_db, "github-boss")
actor = await Account.get_or_create(druks_db, "github-actor")
if actor_is_connected:
await VaultSecret.connect(
druks_db,
"mcp:renamed_company_tracker",
account_id=actor.id,
refresh_token="actor-token",
scopes=[],
identity={"authority": tracker.authority, "subject": "provider-actor-1"},
identity_status=IdentityStatus.RESOLVED,
)
await VaultSecret.connect(
druks_db,
"mcp:renamed_company_tracker",
Expand Down Expand Up @@ -66,7 +78,11 @@ async def get_settings(cls):
identity={"base_url": "https://company.atlassian.net", "email": "druks@company.test"},
secrets={"api_token": "token", "webhook_secret": "hook"},
)
events = JiraEvents(request=SimpleNamespace(), kwargs={}, settings=make_settings(tmp_path))
events = JiraEvents(
request=SimpleNamespace(headers={"x-jira-initiator": "provider-actor-1"}),
kwargs={},
settings=make_settings(tmp_path),
)
events._data_cached = {
"issue": {
"key": item.ticket_key,
Expand All @@ -91,6 +107,7 @@ async def get_settings(cls):
request=SimpleNamespace(), kwargs={}, settings=make_settings(tmp_path)
)
events._data_cached = {
"actor": {"id": "provider-actor-1"},
"data": {
"identifier": item.ticket_key,
"title": item.title,
Expand All @@ -107,10 +124,10 @@ async def get_settings(cls):
await events.on_state_transition()

assert len(started) == 1
assert started[0]["account_id"] == owner.id
assert started[0]["account_id"] == (actor.id if actor_is_connected else owner.id)


async def test_unconnected_assignee_uses_the_default_account(druks_db, started):
async def test_unconnected_actor_and_assignee_use_the_default_account(druks_db, started):
await Account.get_or_create(druks_db, "boss@company.test")
await connect_service(
"linear",
Expand All @@ -128,6 +145,7 @@ async def test_unconnected_assignee_uses_the_default_account(druks_db, started):
"url": f"https://tracker.example/{item.ticket_key}",
"project_name": "company/app",
"labels": [],
"actor_id": "unconnected-actor",
"assignee_id": "provider-user-1",
"assignee_email": "boss@company.test",
"assignee_name": "Boss",
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/software_factory/test_build_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def _ticket(item, **overrides) -> dict:
"url": f"https://tracker.test/{item.ticket_key}",
"project_name": "r",
"labels": [],
"actor_id": None,
"assignee_id": None,
"assignee_email": None,
"assignee_name": None,
Expand Down Expand Up @@ -167,6 +168,7 @@ async def fake_start(cls, **kwargs):
"url": "https://tracker.test/ACME-11",
"project_name": "no-such-project",
"labels": [],
"actor_id": None,
"assignee_id": None,
"assignee_email": None,
"assignee_name": None,
Expand Down
8 changes: 7 additions & 1 deletion backend/tests/software_factory/test_webhooks_jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _jira_payload(*, key="IT-12", status="Open", project="acme-app", labels=None
"url": None,
"project_name": project,
"labels": labels or [],
"actor_id": None,
"assignee_id": None,
"assignee_email": "dev@acme.co",
"assignee_name": "Dev",
Expand Down Expand Up @@ -110,13 +111,18 @@ async def _emit(event_type, **kwargs):

await _connect_jira()
monkeypatch.setattr(webhook_module, "publish", _emit)
await _provider(tmp_path, payload=_issue(key="IT-9", status="Ready")).on_issue_event()
await _provider(
tmp_path,
payload=_issue(key="IT-9", status="Ready"),
headers={"x-jira-initiator": "actor-1"},
).on_issue_event()

assert captured["event"] == "ticket.transitioned"
payload = captured["payload"]
assert payload["source"] == "jira"
assert payload["identifier"] == "IT-9"
assert payload["status"] == "Ready"
assert payload["actor_id"] == "actor-1"
assert payload["assignee_email"] == "dev@acme.co"
assert payload["url"] == "https://jira.test/browse/IT-9"

Expand Down
2 changes: 2 additions & 0 deletions backend/tests/software_factory/test_webhooks_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def _transition():
"action": "update",
"type": "Issue",
"updatedFrom": {"stateId": "old-state"},
"actor": {"id": "actor-1", "type": "user"},
"data": {
"identifier": "ACME-7",
"title": "Add an endpoint",
Expand Down Expand Up @@ -102,6 +103,7 @@ async def test_emits_normalized_ticket_transition(tmp_path, monkeypatch):
"url": "https://linear.app/acme/issue/ACME-7",
"project_name": "acme-app",
"labels": [],
"actor_id": "actor-1",
"assignee_id": "user-7",
"assignee_email": "dev@acme.co",
"assignee_name": "Dev",
Expand Down
23 changes: 14 additions & 9 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,17 +412,22 @@ Webhook URLs remain `/_external/linear/events/` and

Select **Issue data (Jira format)** as its body.
Druks accepts the REST issue JSON under `issue`. Put the shared token in the
`x-druks-webhook-token` header. `druks doctor` treats a disconnected Linear or
Jira identity as optional when that tracker is not selected. It reports pending
setup if the selected tracker is Linear or Jira and that identity is missing.
`x-druks-webhook-token` header. Put `{{initiator.accountId}}` in the
`x-jira-initiator` header, so Druks knows who moved the ticket. See
[Jira's user smart values](https://support.atlassian.com/cloud-automation/docs/jira-smart-values-users/).
`druks doctor` treats a disconnected Linear or Jira identity as optional when
that tracker is not selected. It reports pending setup if the selected tracker
is Linear or Jira and that identity is missing.

Software Factory starts a Linear or Jira build under the Druks account of the
ticket assignee. It finds that account from the assignee ID in the webhook. The
ID must match the [provider account](#oauth-grant-identity) of an MCP connection
that the assignee made with **Connect your account**. A connection for everyone
has no account, so it does not match. If no account or more than one account
matches, the build uses the default account. A **druks** ticket build uses the
account of the ticket assignee.
person who moved the ticket. If that person has no account, the build uses the
account of the ticket assignee, and then the default account. Linear sends that
person in its webhook. Jira sends that person in the `x-jira-initiator` header.
A person has an account when their tracker ID matches the
[provider account](#oauth-grant-identity) of an MCP connection that they made
with **Connect your account**. A connection for everyone has no account, so it
does not match. Two matching accounts also do not match. A **druks** ticket
build uses the account of the ticket assignee.

## WhatsApp

Expand Down
Loading