From fa7ffab087104b8ea5636f79a32b274b0b18e869 Mon Sep 17 00:00:00 2001 From: Paulo Date: Fri, 2 Oct 2026 21:34:32 +0200 Subject: [PATCH] DRU-695 -- Start a tracker build under the person who moved the ticket --- .../druks/contrib/software_factory/models.py | 1 + .../software_factory/ticketing/base.py | 4 +-- .../software_factory/ticketing/druks.py | 2 +- .../contrib/software_factory/webhooks.py | 3 +++ .../contrib/software_factory/workflows.py | 5 ++-- .../software_factory/test_board_routes.py | 1 + .../test_build_attribution.py | 26 ++++++++++++++++--- .../software_factory/test_build_dispatch.py | 2 ++ .../software_factory/test_webhooks_jira.py | 8 +++++- .../software_factory/test_webhooks_linear.py | 2 ++ docs/configuration.md | 23 +++++++++------- 11 files changed, 58 insertions(+), 19 deletions(-) diff --git a/backend/druks/contrib/software_factory/models.py b/backend/druks/contrib/software_factory/models.py index 17133597d..49966f709 100644 --- a/backend/druks/contrib/software_factory/models.py +++ b/backend/druks/contrib/software_factory/models.py @@ -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, diff --git a/backend/druks/contrib/software_factory/ticketing/base.py b/backend/druks/contrib/software_factory/ticketing/base.py index 385eee5e5..bb47089e9 100644 --- a/backend/druks/contrib/software_factory/ticketing/base.py +++ b/backend/druks/contrib/software_factory/ticketing/base.py @@ -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: diff --git a/backend/druks/contrib/software_factory/ticketing/druks.py b/backend/druks/contrib/software_factory/ticketing/druks.py index 8627e2443..0229a9db3 100644 --- a/backend/druks/contrib/software_factory/ticketing/druks.py +++ b/backend/druks/contrib/software_factory/ticketing/druks.py @@ -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 diff --git a/backend/druks/contrib/software_factory/webhooks.py b/backend/druks/contrib/software_factory/webhooks.py index 352bd887f..a910109f6 100644 --- a/backend/druks/contrib/software_factory/webhooks.py +++ b/backend/druks/contrib/software_factory/webhooks.py @@ -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={ @@ -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"), @@ -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"), diff --git a/backend/druks/contrib/software_factory/workflows.py b/backend/druks/contrib/software_factory/workflows.py index f18a4ed4f..ac8978348 100644 --- a/backend/druks/contrib/software_factory/workflows.py +++ b/backend/druks/contrib/software_factory/workflows.py @@ -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, diff --git a/backend/tests/software_factory/test_board_routes.py b/backend/tests/software_factory/test_board_routes.py index 3c6a20fc0..0ca57416a 100644 --- a/backend/tests/software_factory/test_board_routes.py +++ b/backend/tests/software_factory/test_board_routes.py @@ -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, diff --git a/backend/tests/software_factory/test_build_attribution.py b/backend/tests/software_factory/test_build_attribution.py index eb2bcdbf5..d3eaa14ee 100644 --- a/backend/tests/software_factory/test_build_attribution.py +++ b/backend/tests/software_factory/test_build_attribution.py @@ -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", @@ -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, @@ -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, @@ -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", @@ -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", diff --git a/backend/tests/software_factory/test_build_dispatch.py b/backend/tests/software_factory/test_build_dispatch.py index 13d11dd4e..e23fb9bdb 100644 --- a/backend/tests/software_factory/test_build_dispatch.py +++ b/backend/tests/software_factory/test_build_dispatch.py @@ -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, @@ -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, diff --git a/backend/tests/software_factory/test_webhooks_jira.py b/backend/tests/software_factory/test_webhooks_jira.py index 6c78d754f..d497d9f3f 100644 --- a/backend/tests/software_factory/test_webhooks_jira.py +++ b/backend/tests/software_factory/test_webhooks_jira.py @@ -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", @@ -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" diff --git a/backend/tests/software_factory/test_webhooks_linear.py b/backend/tests/software_factory/test_webhooks_linear.py index 37d723307..947421e9d 100644 --- a/backend/tests/software_factory/test_webhooks_linear.py +++ b/backend/tests/software_factory/test_webhooks_linear.py @@ -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", @@ -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", diff --git a/docs/configuration.md b/docs/configuration.md index b817fdbac..d06bf9ee7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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