From f0388b2f5b9142767dee55a8c216d5aeb9219bbc Mon Sep 17 00:00:00 2001 From: Paulo Date: Fri, 2 Oct 2026 21:20:06 +0200 Subject: [PATCH] DRU-692 -- Read account sign-ins directly from the service --- backend/druks/contrib/software_factory/app.py | 2 -- .../contrib/software_factory/workflows.py | 2 +- backend/druks/services/base.py | 22 +++++++++++-------- docs/writing-an-app.md | 13 +++++++++++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/backend/druks/contrib/software_factory/app.py b/backend/druks/contrib/software_factory/app.py index 659f2f73..b03a9b41 100644 --- a/backend/druks/contrib/software_factory/app.py +++ b/backend/druks/contrib/software_factory/app.py @@ -84,8 +84,6 @@ class SoftwareFactory(App): "Turns a ticket into a pull request — it plans the change, builds it, and " "gates on you before shipping. Reviews a pull request when asked." ) - # The requester's GitHub sign-in names them in a review. - github = services.Github.with_scopes() class Settings(AppSettings): tracker: Literal["none", "linear", "jira", "druks"] = Field( diff --git a/backend/druks/contrib/software_factory/workflows.py b/backend/druks/contrib/software_factory/workflows.py index f18a4ed4..8c3eaefa 100644 --- a/backend/druks/contrib/software_factory/workflows.py +++ b/backend/druks/contrib/software_factory/workflows.py @@ -543,7 +543,7 @@ async def get_prompt_context(self, **context: Any) -> dict[str, Any]: ) # The reviewer writes on GitHub, so the requester goes by their GitHub login. account = await Account.get_for_run(db_session(), self.account_id) - sign_ins = await SoftwareFactory.github.list_for_account(account.id) + sign_ins = await Github.list_for_account(account.id) requested_by = sign_ins[0].identity["login"] if sign_ins else account.username return { "siblings": await project_repo.siblings(), diff --git a/backend/druks/services/base.py b/backend/druks/services/base.py index 5b62c618..b2e29cc4 100644 --- a/backend/druks/services/base.py +++ b/backend/druks/services/base.py @@ -24,9 +24,8 @@ class Connection: - """One signed-in provider account, reached through the app's - declared handle. ``get_access_token`` and ``disconnect`` act on this - sign-in only.""" + """One signed-in provider account. ``get_access_token`` and ``disconnect`` + act on this sign-in only.""" def __init__(self, service: "type[Service]", row: VaultSecret) -> None: self.service = service @@ -108,12 +107,7 @@ def connect_url(self) -> str: return f"/api/oauth/{self.service.slug}/connect?next=/{self.owner.name}" async def list_for_account(self, account_id: str) -> list[Connection]: - return [ - Connection(self.service, row) - for row in await VaultSecret.list_account_connections( - db_session(), Audience.service(self.service.slug), account_id - ) - ] + return await self.service.list_for_account(account_id) async def get(self, connection_id: str) -> Connection | None: row = await db_session().get(VaultSecret, connection_id) @@ -278,6 +272,16 @@ def with_scopes(cls, *scopes: str) -> ScopedService: raise TypeError(f"{cls.__name__} declares no OAuth endpoints") return ScopedService(cls, scopes) + @classmethod + async def list_for_account(cls, account_id: str) -> list[Connection]: + """The account's live sign-ins, without declaring an app's scope requirements.""" + return [ + Connection(cls, row) + for row in await VaultSecret.list_account_connections( + db_session(), Audience.service(cls.slug), account_id + ) + ] + @classmethod def declarations(cls) -> "list[ScopedService]": return [ diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index a7828185..da382409 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -1504,6 +1504,19 @@ for connection in await NightWatch.acme.list_for_account(account_id): token = await connection.get_access_token() ``` +Read existing sign-in facts directly from the service when you do not need to +declare scopes: + +```python +from druks.core.services import Github + +sign_ins = await Github.list_for_account(self.account_id) +``` + +Each connection's `identity["login"]` names the GitHub user. The direct read +does not add the app to the service's scope declarations. An empty list means +the account has no live sign-in. Both reads exclude revoked connections. + `account_id` is the caller: `self.account_id` in a run body, `current_account_id.get()` in a route, the handler's argument in a subscriber, the platform's argument in `list_summaries`. `await NightWatch.acme.get(connection_id)` returns one connection