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
46 changes: 46 additions & 0 deletions src/goodeye_cli/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import platform
import sys

import typer
from rich.console import Console
Expand All @@ -13,6 +14,50 @@
from goodeye_cli.config import get_server, save_client_config, save_credentials


def _is_tty() -> bool:
"""Return True when stdout is an interactive terminal."""
return sys.stdout.isatty()


def _print_post_login_banner(server: str, api_key: str, console: Console) -> None:
"""Print a one-line nudge about shared workflows and pending invitations.

Silent and non-blocking: any error, non-TTY session, or offline state
results in nothing printed. Login always succeeds regardless.
"""
if not _is_tty():
return
try:
with GoodeyeClient(server, api_key=api_key, timeout=0.9) as client:
workflows = client.list_workflows(filter_="shared-with-me")
invitations = client.list_invitations(filter_="received", state="pending")
n = len(workflows.items)
m = len(invitations.items)
# A present next_cursor means the first page did not exhaust the
# results, so the counts are lower bounds; render them as "N+".
wf_more = workflows.next_cursor is not None
inv_more = invitations.next_cursor is not None
except Exception:
return
if n == 0 and m == 0:
return
wf_count = f"{n}+" if wf_more else str(n)
inv_count = f"{m}+" if inv_more else str(m)
wf_noun = "workflow" if n == 1 and not wf_more else "workflows"
inv_noun = "invitation" if m == 1 and not inv_more else "invitations"
# Name only the nonzero categories so a user with shared workflows but no
# invitations (or vice versa) never sees an awkward "0 ..." count.
clauses: list[str] = []
commands: list[str] = []
if n > 0:
clauses.append(f"{wf_count} {wf_noun} shared with you")
commands.append("'goodeye workflows list --filter shared-with-me'")
if m > 0:
clauses.append(f"{inv_count} pending {inv_noun}")
commands.append("'goodeye invitations list'")
console.print(f"You have {' and '.join(clauses)}. Run {' or '.join(commands)} to see them.")


def run_interactive_login(server: str, console: Console, referral_code: str | None) -> None:
"""Run the browser/device-code sign-in flow and save local credentials.

Expand All @@ -37,6 +82,7 @@ def run_interactive_login(server: str, console: Console, referral_code: str | No
path = save_credentials({"api_key": api_key, "server": server})
console.print(f"[green]Signed in.[/green] Credentials saved to {path}")
_maybe_redeem_referral(server, api_key, referral_code, console)
_print_post_login_banner(server, api_key, console)


def login(
Expand Down
229 changes: 229 additions & 0 deletions tests/test_login_banner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""Tests for the post-login shared-with-you banner.

After a successful interactive login the CLI prints a one-line nudge
when workflows are shared with the user or invitations are pending.
The banner is silent on non-TTY sessions and on any list-endpoint error.
"""

from __future__ import annotations

from unittest.mock import patch

import httpx
import respx
from typer.testing import CliRunner

from goodeye_cli.app import app
from goodeye_cli.config import ConfigPaths

SERVER = "https://example.test"
DEVICE_URI = "https://api.workos.com/user_management/authorize/device"
TOKEN_URI = "https://api.workos.com/user_management/authenticate"

_CLIENT_CONFIG_BODY = {
"workos_client_id": "client_X",
"workos_device_authorization_uri": DEVICE_URI,
"workos_token_uri": TOKEN_URI,
}

_WORKFLOW_ITEMS = [
{"id": "wf-01", "name": "alpha", "current_version": 1},
{"id": "wf-02", "name": "beta", "current_version": 2},
]

_INVITATION_ITEMS = [
{
"id": "inv-01",
"kind": "team_membership",
"target_id": "t-01",
"target_label": "@acme",
"proposed_by_handle": "alice",
"proposed_to_handle": "bob",
"created_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-02-01T00:00:00Z",
},
]


def _env(monkeypatch, tmp_config_paths: ConfigPaths) -> None:
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_config_paths.config_dir.parent))
monkeypatch.setenv("GOODEYE_SERVER", SERVER)
monkeypatch.delenv("GOODEYE_API_KEY", raising=False)


def test_login_banner_interactive_shows_counts(tmp_config_paths: ConfigPaths, monkeypatch) -> None:
"""Interactive login prints shared-workflow and invitation counts when both are nonzero."""
_env(monkeypatch, tmp_config_paths)

with (
respx.mock,
patch(
"goodeye_cli.commands.login.device_code_login",
return_value="good_live_EXAMPLE_banner1",
),
patch("goodeye_cli.commands.login.save_client_config"),
patch("goodeye_cli.commands.login._is_tty", return_value=True),
):
respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock(
return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY)
)
respx.get(f"{SERVER}/v1/workflows").mock(
return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None})
)
respx.get(f"{SERVER}/v1/invitations").mock(
return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None})
)

runner = CliRunner()
result = runner.invoke(app, ["login"])

assert result.exit_code == 0, result.output
# Normalize whitespace to handle Rich's line wrapping in the captured output.
flat = " ".join(result.output.split())
assert "shared with you" in flat
assert "pending invitation" in flat
assert "goodeye workflows list --filter shared-with-me" in flat
assert "goodeye invitations list" in flat
# Confirm the counts appear
assert "2" in flat # 2 workflows
assert "1" in flat # 1 invitation


def test_login_banner_only_workflows_omits_zero_invitations(
tmp_config_paths: ConfigPaths, monkeypatch
) -> None:
"""With shared workflows but no pending invitations, the banner names only workflows."""
_env(monkeypatch, tmp_config_paths)

with (
respx.mock,
patch(
"goodeye_cli.commands.login.device_code_login",
return_value="good_live_EXAMPLE_banner4",
),
patch("goodeye_cli.commands.login.save_client_config"),
patch("goodeye_cli.commands.login._is_tty", return_value=True),
):
respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock(
return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY)
)
respx.get(f"{SERVER}/v1/workflows").mock(
return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None})
)
respx.get(f"{SERVER}/v1/invitations").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)

runner = CliRunner()
result = runner.invoke(app, ["login"])

assert result.exit_code == 0, result.output
flat = " ".join(result.output.split())
assert "shared with you" in flat
assert "goodeye workflows list --filter shared-with-me" in flat
# The zero category is omitted entirely: no "0 ..." count, no invitation copy.
assert "pending invitation" not in flat
assert "goodeye invitations list" not in flat


def test_login_banner_only_invitations_omits_zero_workflows(
tmp_config_paths: ConfigPaths, monkeypatch
) -> None:
"""With pending invitations but no shared workflows, the banner names only invitations."""
_env(monkeypatch, tmp_config_paths)

with (
respx.mock,
patch(
"goodeye_cli.commands.login.device_code_login",
return_value="good_live_EXAMPLE_banner5",
),
patch("goodeye_cli.commands.login.save_client_config"),
patch("goodeye_cli.commands.login._is_tty", return_value=True),
):
respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock(
return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY)
)
respx.get(f"{SERVER}/v1/workflows").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
respx.get(f"{SERVER}/v1/invitations").mock(
return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None})
)

runner = CliRunner()
result = runner.invoke(app, ["login"])

assert result.exit_code == 0, result.output
flat = " ".join(result.output.split())
assert "pending invitation" in flat
assert "goodeye invitations list" in flat
# The zero category is omitted entirely: no "0 ..." count, no workflow copy.
assert "shared with you" not in flat
assert "goodeye workflows list --filter shared-with-me" not in flat


def test_login_banner_no_tty_prints_nothing(tmp_config_paths: ConfigPaths, monkeypatch) -> None:
"""Non-TTY session produces no banner and does not call the list endpoints."""
_env(monkeypatch, tmp_config_paths)

with (
respx.mock,
patch(
"goodeye_cli.commands.login.device_code_login",
return_value="good_live_EXAMPLE_banner2",
),
patch("goodeye_cli.commands.login.save_client_config"),
patch("goodeye_cli.commands.login._is_tty", return_value=False),
):
respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock(
return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY)
)
wf_route = respx.get(f"{SERVER}/v1/workflows").mock(
return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None})
)
inv_route = respx.get(f"{SERVER}/v1/invitations").mock(
return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None})
)

runner = CliRunner()
result = runner.invoke(app, ["login"])

assert result.exit_code == 0, result.output
assert "shared with you" not in result.output
# List endpoints must not be called when not a TTY
assert wf_route.call_count == 0
assert inv_route.call_count == 0


def test_login_banner_api_error_prints_nothing_and_login_succeeds(
tmp_config_paths: ConfigPaths, monkeypatch
) -> None:
"""When a list endpoint returns an error the banner is suppressed and login exits 0."""
_env(monkeypatch, tmp_config_paths)

with (
respx.mock,
patch(
"goodeye_cli.commands.login.device_code_login",
return_value="good_live_EXAMPLE_banner3",
),
patch("goodeye_cli.commands.login.save_client_config"),
patch("goodeye_cli.commands.login._is_tty", return_value=True),
):
respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock(
return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY)
)
respx.get(f"{SERVER}/v1/workflows").mock(
return_value=httpx.Response(
500,
json={"error": "server_error", "message": "Internal error."},
)
)

runner = CliRunner()
result = runner.invoke(app, ["login"])

assert result.exit_code == 0, result.output
assert "shared with you" not in result.output
assert "Signed in" in result.output
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.