From efded782f6073d61f4bc5685013d646221ac3da4 Mon Sep 17 00:00:00 2001 From: Nihit Gupta Date: Mon, 6 Jul 2026 18:50:50 +0530 Subject: [PATCH 1/4] Add Gitlab CLI integration --- .../inbound/cli/auth/auth_commands.py | 13 +- .../adapters/inbound/cli/auth/gitlab_auth.py | 322 +++++++++ .../inbound/cli/auth/gitlab_commands.py | 292 ++++++++ .../adapters/inbound/cli/auth/gitlab_read.py | 111 +++ .../adapters/outbound/cli_auth/credentials.py | 32 + .../outbound/cli_auth/credentials_store.py | 203 ++++++ .../outbound/cli_auth/gitlab_client.py | 151 ++++ .../outbound/cli_auth/gitlab_read_client.py | 212 ++++++ .../outbound/cli_auth/integration_profile.py | 37 + .../outbound/cli_auth/integration_verify.py | 38 + .../outbound/cli_auth/provider_config.py | 18 +- .../domain/ports/cli_auth/credentials.py | 25 + potpie/context-engine/tests/_auth_fakes.py | 69 ++ .../tests/unit/test_cli_gitlab.py | 389 +++++++++++ .../tests/unit/test_cli_gitlab_commands.py | 650 ++++++++++++++++++ .../tests/unit/test_gitlab_client.py | 280 ++++++++ 16 files changed, 2840 insertions(+), 2 deletions(-) create mode 100644 potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py create mode 100644 potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py create mode 100644 potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py create mode 100644 potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py create mode 100644 potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py create mode 100644 potpie/context-engine/tests/unit/test_cli_gitlab.py create mode 100644 potpie/context-engine/tests/unit/test_cli_gitlab_commands.py create mode 100644 potpie/context-engine/tests/unit/test_gitlab_client.py diff --git a/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py b/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py index 3c25feb90..3f45ac051 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py @@ -83,7 +83,7 @@ confluence_app = typer.Typer(help="Confluence integration and read.") _OAUTH_CALLBACK_TIMEOUT = 300.0 -_ALL_PROVIDERS: tuple[Provider, ...] = ("github", "linear", "jira", "confluence") +_ALL_PROVIDERS: tuple[Provider, ...] = ("github", "linear", "jira", "confluence", "gitlab") IntegrationAuthProvider = Literal["linear", "atlassian", "jira", "confluence"] @@ -501,6 +501,8 @@ def integration_status( credentials = ensure_valid_integration_tokens(provider) elif provider == "github": credentials = get_store().get_provider_credentials("github") + elif provider == "gitlab": + credentials = get_integration_tokens(provider) else: credentials = get_integration_tokens(provider) except (ValueError, RuntimeError, ProviderCredentialError) as exc: @@ -543,6 +545,8 @@ def integration_status( parts.append(f"site={_esc(row['site_name'])}") if row.get("site_url"): parts.append(f"url={_esc(row['site_url'])}") + if row.get("instance_host"): + parts.append(f"instance={_esc(row['instance_host'])}") if row.get("expires_at") is not None: parts.append(f"expires_at={_esc(row['expires_at'])}") if row.get("cloud_id"): @@ -632,6 +636,11 @@ def auth_logout(provider: str) -> None: github_logout_impl() return + if key == "gitlab": + from adapters.inbound.cli.auth.gitlab_commands import gitlab_logout + + gitlab_logout() + return store = get_store() existing = get_integration_status(key) @@ -852,9 +861,11 @@ def register_integration_commands(root: typer.Typer) -> None: git_app, github_app, ) + from adapters.inbound.cli.auth.gitlab_commands import gitlab_app root.add_typer(github_app, name="github") root.add_typer(git_app, name="git") + root.add_typer(gitlab_app, name="gitlab") root.add_typer(linear_app, name="linear") root.add_typer(jira_app, name="jira") root.add_typer(confluence_app, name="confluence") diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py new file mode 100644 index 000000000..82c6713b2 --- /dev/null +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py @@ -0,0 +1,322 @@ +"""Interactive GitLab PAT login command. + +The HTTP client (verification + profile fetch) lives in +``adapters.outbound.cli_auth.gitlab_client``; this inbound module owns the +interactive flow (prompts, output, exit codes). +""" + +from __future__ import annotations + +import select +import sys +import time +import webbrowser +from typing import Any, NoReturn + +import typer + +import click +from click.exceptions import Abort + +if sys.platform == "win32": + import msvcrt +else: + msvcrt = None + +EXIT_CANCELLED = 130 +GITLAB_AUTO_OPEN_SECONDS = 10 +_GITLAB_OPEN_PROMPT_PREFIX = ( + "Press Enter to open now, or browser opens in " +) + +from adapters.outbound.cli_auth.gitlab_client import ( + GitLabAuthErrorKind, + instance_host, + normalize_instance_url, + parse_user_profile, + verify_instance_access, + verify_read_api_scope, +) +from adapters.outbound.cli_auth.credentials_store import ( + ProviderCredentialError, + credentials_path, + get_gitlab_credentials, + get_integration_status, + save_gitlab_credentials, +) +from adapters.inbound.cli.commands._common import EXIT_AUTH, get_store +from adapters.inbound.cli.ui.output import emit_error, print_plain_line +from adapters.outbound.cli_auth.provider_config import ( + GITLAB_DEFAULT_INSTANCE, + GITLAB_RECOMMENDED_SCOPES, + gitlab_pat_page_url, +) + + +def _guard_typer_prompt(callback): + try: + return callback() + except click.Abort: + raise KeyboardInterrupt from None + + +def _prompt_instance_url(default: str = "") -> str: + prompt_default = default or "gitlab.com" + raw = _guard_typer_prompt( + lambda: typer.prompt( + "Enter your GitLab instance URL", + default=prompt_default, + ).strip() + ) + if raw in ("gitlab.com", "https://gitlab.com"): + return GITLAB_DEFAULT_INSTANCE + return raw + + +def _prompt_pat() -> str: + pat = _guard_typer_prompt( + lambda: typer.prompt( + "Enter your personal access token", + hide_input=True, + ).strip() + ) + if not pat: + raise typer.Exit(code=1) + return pat + + +def _detect_gitlab_from_git_remote() -> str | None: + """Try to guess the GitLab instance from the current repo's git remote.""" + try: + from adapters.inbound.cli.repo_location import current_git_remote + from pathlib import Path + + remote = current_git_remote(Path.cwd().resolve()) + if remote and "gitlab" in remote.lower(): + host = remote.split("/")[0] if "/" in remote else "" + if host and "gitlab" in host.lower(): + return f"https://{host}" + except Exception: + pass + return None + + +def _write_inline_prompt(message: str) -> None: + sys.stdout.write(f"\r\033[K{message}") + sys.stdout.flush() + + +def _finish_inline_prompt() -> None: + sys.stdout.write("\r\033[K\n") + sys.stdout.flush() + + +def _stdin_enter_pressed_windows(*, timeout: float) -> bool: + if msvcrt is None: + return False + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if msvcrt.kbhit(): + char = msvcrt.getwch() + if char in ("\r", "\n"): + return True + time.sleep(0.05) + return False + + +def _wait_for_enter_or_auto_open( + *, + seconds: int = GITLAB_AUTO_OPEN_SECONDS, + input_stream=None, +) -> None: + input_stream = input_stream or sys.stdin + use_windows_stdin = input_stream is sys.stdin and sys.platform == "win32" + for remaining in range(seconds, 0, -1): + _write_inline_prompt(f"{_GITLAB_OPEN_PROMPT_PREFIX}{remaining}s") + if use_windows_stdin: + if _stdin_enter_pressed_windows(timeout=1.0): + _finish_inline_prompt() + return + continue + try: + ready, _, _ = select.select([input_stream], [], [], 1) + except (OSError, TypeError, ValueError): + time.sleep(1) + ready = [] + if ready: + input_stream.readline() + _finish_inline_prompt() + return + _finish_inline_prompt() + + +def _cancel_gitlab_login() -> NoReturn: + typer.echo() + print_plain_line("GitLab login cancelled.", as_json=False) + raise typer.Exit(code=EXIT_CANCELLED) from None + + +def _is_gitlab_login_cancel(exc: BaseException) -> bool: + return isinstance(exc, (Abort, KeyboardInterrupt, EOFError)) or ( + exc.__class__.__name__ == "Abort" + ) + + +def _auth_failure_message( + error_kind: GitLabAuthErrorKind | None, + instance_url: str, +) -> str: + scopes = ", ".join(GITLAB_RECOMMENDED_SCOPES) + lines = [ + "Could not authenticate with GitLab.", + f" - Instance: {instance_url}", + f" - Required scopes: {scopes}", + ] + if error_kind == GitLabAuthErrorKind.INVALID_CREDENTIALS: + lines.insert(1, " - Invalid personal access token") + elif error_kind == GitLabAuthErrorKind.INSUFFICIENT_SCOPES: + lines.insert(1, f" - Token is missing required scopes ({scopes})") + elif error_kind == GitLabAuthErrorKind.INSTANCE_UNREACHABLE: + lines.insert(1, f" - Cannot reach {instance_url} (check URL, DNS, TLS)") + return "\n".join(lines) + + +def _open_gitlab_pat_page(inst_url: str) -> None: + """Show setup steps, then open the GitLab PAT page after countdown or Enter.""" + scopes = ", ".join(GITLAB_RECOMMENDED_SCOPES) + print_plain_line("GitLab login — Personal Access Token", as_json=False) + for line in ( + f" • Create a token at your GitLab instance with scope: {scopes}", + " • One token per instance; it works for projects, MRs, and issues", + f" • Press Enter to open now, or the browser opens in " + f"{GITLAB_AUTO_OPEN_SECONDS}s", + ): + print_plain_line(line, as_json=False) + try: + _wait_for_enter_or_auto_open() + except BaseException as exc: + if _is_gitlab_login_cancel(exc): + _cancel_gitlab_login() + raise + + pat_url = gitlab_pat_page_url(inst_url) + print_plain_line("Opening token settings now...", as_json=False) + + opened = webbrowser.open(pat_url, new=1) + if not opened: + print_plain_line( + "Could not open a browser. Open this URL:", + as_json=False, + ) + print_plain_line(pat_url, as_json=False, markup=False) + return + print_plain_line("Paste the token below when you are ready.", as_json=False) + + +def run_gitlab_pat_auth( + *, + force: bool = False, + as_json: bool = False, + verbose: bool = False, + instance: str | None = None, + token: str | None = None, +) -> None: + """Authenticate with a GitLab instance using a Personal Access Token.""" + supplied = bool((instance or "").strip() and (token or "").strip()) + + if not force: + host_to_check = ( + instance_host(instance) if instance else None + ) + existing = get_gitlab_credentials(instance_host=host_to_check) + if existing.get("personal_access_token"): + inst = existing.get("instance_url") or existing.get("instance_host", "") + print_plain_line( + f"GitLab is already connected to {inst}.", + as_json=as_json, + json_payload={ + "ok": True, + "already_connected": True, + "provider": "gitlab", + "instance_url": inst, + }, + ) + return + + if not sys.stdin.isatty() and not supplied: + emit_error( + "GitLab authentication requires a terminal", + "Run in an interactive shell, or pass --instance and --token.", + verbose=verbose, + ) + raise typer.Exit(code=1) + + if supplied: + inst_url = normalize_instance_url(instance or "") or GITLAB_DEFAULT_INSTANCE + pat = (token or "").strip() + else: + git_hint = _detect_gitlab_from_git_remote() + inst_url = normalize_instance_url( + _prompt_instance_url(default=git_hint or "") + ) or GITLAB_DEFAULT_INSTANCE + if not as_json: + _open_gitlab_pat_page(inst_url) + else: + webbrowser.open(gitlab_pat_page_url(inst_url), new=1) + pat = _prompt_pat() + + ok, error_kind, user_data = verify_instance_access(inst_url, pat) + if not ok: + emit_error( + "GitLab authentication failed", + _auth_failure_message(error_kind, inst_url), + verbose=verbose, + ) + raise typer.Exit(code=EXIT_AUTH) + + scope_ok, scope_error = verify_read_api_scope(inst_url, pat) + if not scope_ok: + emit_error( + "GitLab authentication failed", + _auth_failure_message(scope_error, inst_url), + verbose=verbose, + ) + raise typer.Exit(code=EXIT_AUTH) + + account = parse_user_profile(user_data) + host = instance_host(inst_url) + + payload: dict[str, Any] = { + "auth_type": "personal_access_token", + "instance_url": inst_url, + "instance_host": host, + "personal_access_token": pat, + "stored_at": time.time(), + } + + try: + save_gitlab_credentials(payload, account=account) + except ProviderCredentialError as exc: + emit_error( + "GitLab credential storage failed", str(exc), verbose=verbose, + ) + raise typer.Exit(code=EXIT_AUTH) from exc + + username = account.get("username") or account.get("name") or "user" + summary = ( + f"Connected GitLab ({host}) as {username}. " + f"Stored token in local credentials; metadata saved to {credentials_path()}." + ) + print_plain_line( + summary, + as_json=as_json, + json_payload={ + "ok": True, + "provider": "gitlab", + "instance_url": inst_url, + "instance_host": host, + "username": username, + "path": str(credentials_path()), + "token_storage": "file", + }, + ) diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py new file mode 100644 index 000000000..0ee91bd38 --- /dev/null +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py @@ -0,0 +1,292 @@ +"""GitLab CLI authentication and read commands.""" + +from __future__ import annotations + +from typing import Any + +import typer + +from bootstrap.runtime_settings import ensure_runtime_environment_loaded +from adapters.inbound.cli.auth.gitlab_auth import run_gitlab_pat_auth +from adapters.inbound.cli.auth.gitlab_read import ( + run_gitlab_select_flow, +) +from adapters.outbound.cli_auth.gitlab_read_client import ( + GitLabReadError, + fetch_gitlab_projects, +) +from adapters.inbound.cli.commands._common import EXIT_AUTH, EXIT_UNAVAILABLE, get_store +from adapters.outbound.cli_auth.credentials_store import ( + CredentialStoreError, + ProviderCredentialError, + credentials_path, + get_integration_status, + list_gitlab_instances, + clear_gitlab_credentials, +) +from adapters.inbound.cli.telemetry.usage_events import ( + capture_usage_command_succeeded, +) +from adapters.inbound.cli.ui.output import emit_error, print_json_blob, print_plain_line +from rich.markup import escape + +gitlab_app = typer.Typer(help="GitLab integration.") + + +def _flags() -> tuple[bool, bool]: + from adapters.inbound.cli.commands._common import is_json, is_verbose + + return is_json(), is_verbose() + + +def _esc(value: Any) -> str: + return escape(str(value or "")) + + +@gitlab_app.command("login") +def gitlab_login( + force: bool = typer.Option( + False, + "--force", + help="Re-authenticate even if GitLab is already connected.", + ), + instance: str | None = typer.Option( + None, + "--instance", + "-i", + help="GitLab instance URL (e.g. https://gitlab.corp.com).", + ), + token: str | None = typer.Option( + None, + "--token", + "-t", + help="Personal access token (non-interactive login).", + ), +) -> None: + """Connect to a GitLab instance with a Personal Access Token.""" + ensure_runtime_environment_loaded() + j, v = _flags() + run_gitlab_pat_auth( + force=force, + as_json=j, + verbose=v, + instance=instance, + token=token, + ) + + +@gitlab_app.command("logout") +def gitlab_logout( + instance: str | None = typer.Option( + None, + "--instance", + "-i", + help="GitLab instance host to disconnect (default: all).", + ), +) -> None: + """Remove stored GitLab credentials.""" + j, v = _flags() + was_authenticated = bool( + get_integration_status("gitlab").get("authenticated") + ) + try: + clear_gitlab_credentials(instance_host=instance) + except (ProviderCredentialError, CredentialStoreError, ValueError) as exc: + emit_error("GitLab logout failed", str(exc), verbose=v) + raise typer.Exit(code=EXIT_AUTH) from exc + + if j: + payload: dict[str, object] = {"ok": True, "provider": "gitlab"} + if instance: + payload["instance"] = instance + if not was_authenticated: + payload["cleared_stale"] = True + print_json_blob(payload, as_json=True) + return + + if was_authenticated: + print_plain_line("Logged out successfully.", as_json=False) + else: + print_plain_line( + "No active session found. Any stale local credentials were removed.", + as_json=False, + ) + + +@gitlab_app.command("ls") +def gitlab_ls() -> None: + """List connected GitLab instances.""" + ensure_runtime_environment_loaded() + j, v = _flags() + try: + rows = list_gitlab_instances() + except (ProviderCredentialError, CredentialStoreError) as exc: + emit_error("GitLab instance list failed", str(exc), verbose=v) + raise typer.Exit(code=EXIT_UNAVAILABLE) from exc + capture_usage_command_succeeded( + command="gitlab ls", + result_kind="provider_list", + item_count=len(rows), + provider="gitlab", + ) + if j: + print_json_blob( + {"ok": True, "provider": "gitlab", "instances": rows}, as_json=True, + ) + return + print_plain_line("GitLab instances:", as_json=False) + if not rows: + print_plain_line(" (none — run: potpie gitlab login)", as_json=False) + return + for row in rows: + active_suffix = " (active)" if row.get("active") else "" + account = row.get("account") or {} + username = account.get("username") or "" + host = _esc(row.get("instance_host")) + user_str = f" ({_esc(username)})" if username else "" + print_plain_line( + f" {host}{user_str}{active_suffix}", + as_json=False, + ) + print_plain_line( + "\nConnect another instance: potpie gitlab login --instance ", + as_json=False, + ) + + +@gitlab_app.command("repos") +def gitlab_repos( + instance: str | None = typer.Option( + None, + "--instance", + "-i", + help="GitLab instance host.", + ), + limit: int = typer.Option(50, "--limit", "-n", min=1, max=100), +) -> None: + """List GitLab projects accessible to the authenticated user.""" + ensure_runtime_environment_loaded() + j, v = _flags() + try: + repos = fetch_gitlab_projects(instance_host=instance, limit=limit) + except GitLabReadError as exc: + emit_error("GitLab project listing failed", str(exc), verbose=v) + raise typer.Exit(code=EXIT_UNAVAILABLE) from exc + capture_usage_command_succeeded( + command="gitlab repos", + result_kind="provider_list", + item_count=len(repos), + provider="gitlab", + ) + if j: + print_json_blob( + {"ok": True, "provider": "gitlab", "count": len(repos), "projects": repos}, + as_json=True, + ) + return + print_plain_line(f"GitLab projects ({len(repos)}):", as_json=False) + if not repos: + print_plain_line(" (none)", as_json=False) + return + for repo in repos: + vis = repo.get("visibility") or "" + vis_label = f" [{vis}]" if vis else "" + print_plain_line( + f" {_esc(repo.get('path_with_namespace'))}{vis_label}", + as_json=False, + ) + + +@gitlab_app.command("select") +def gitlab_select( + instance: str | None = typer.Option( + None, + "--instance", + "-i", + help="GitLab instance host.", + ), + project: str | None = typer.Option( + None, + "--project", + "-p", + help="GitLab project path (e.g. group/repo).", + ), + limit: int = typer.Option(10, "--limit", "-n", min=1, max=50), +) -> None: + """Select a GitLab project, then fetch MRs and issues.""" + ensure_runtime_environment_loaded() + j, v = _flags() + try: + result = run_gitlab_select_flow( + instance_host=instance, project_path=project, limit=limit, + ) + except GitLabReadError as exc: + emit_error("GitLab fetch failed", str(exc), verbose=v) + raise typer.Exit(code=EXIT_UNAVAILABLE) from exc + + capture_usage_command_succeeded( + command="gitlab select", + result_kind="provider_select", + item_count=( + len(result.get("merge_requests") or []) + + len(result.get("issues") or []) + ), + provider="gitlab", + ) + + if j: + print_json_blob({"ok": True, "provider": "gitlab", **result}, as_json=True) + return + + project_label = result.get("workspace_key") or result.get("workspace_name") + print_plain_line( + f"Project: {_esc(project_label)}", + as_json=False, + ) + + mrs = result.get("merge_requests") or [] + if mrs: + print_plain_line(f"\nOpen merge requests ({len(mrs)}):", as_json=False) + for mr in mrs: + _print_mr_row(mr) + else: + print_plain_line("\nNo open merge requests.", as_json=False) + + issues = result.get("issues") or [] + if issues: + print_plain_line(f"\nOpen issues ({len(issues)}):", as_json=False) + for issue in issues: + _print_issue_row(issue) + else: + print_plain_line("\nNo open issues.", as_json=False) + + +def _print_mr_row(row: dict[str, Any]) -> None: + iid = _esc(row.get("iid")) + title = _esc(row.get("title")) + print_plain_line(f"\n !{iid} {title}".rstrip(), as_json=False) + if row.get("author"): + print_plain_line(f" Author: {_esc(row.get('author'))}", as_json=False) + if row.get("source_branch"): + print_plain_line( + f" {_esc(row.get('source_branch'))} → {_esc(row.get('target_branch'))}", + as_json=False, + ) + if row.get("web_url"): + print_plain_line(f" URL: {_esc(row.get('web_url'))}", as_json=False) + + +def _print_issue_row(row: dict[str, Any]) -> None: + iid = _esc(row.get("iid")) + title = _esc(row.get("title")) + print_plain_line(f"\n #{iid} {title}".rstrip(), as_json=False) + if row.get("assignee"): + print_plain_line(f" Assignee: {_esc(row.get('assignee'))}", as_json=False) + if row.get("labels"): + labels = row.get("labels") or [] + if isinstance(labels, list) and labels: + print_plain_line( + f" Labels: {', '.join(str(l) for l in labels)}", as_json=False, + ) + if row.get("web_url"): + print_plain_line(f" URL: {_esc(row.get('web_url'))}", as_json=False) diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py new file mode 100644 index 000000000..4922bfcc4 --- /dev/null +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py @@ -0,0 +1,111 @@ +"""Interactive GitLab read flows (project picker and MR/issue fetch). + +The HTTP/data layer lives in ``adapters.outbound.cli_auth.gitlab_read_client``; +this inbound module owns the interactive project-selection flow. +""" + +from __future__ import annotations + +import sys +from typing import Any +from urllib.parse import quote_plus + +import typer + +from adapters.outbound.cli_auth.gitlab_read_client import ( + GitLabReadError, + fetch_gitlab_issues, + fetch_gitlab_merge_requests, + fetch_gitlab_projects, + load_gitlab_read_credentials, +) +from adapters.outbound.cli_auth.credentials_store import ( + save_gitlab_workspace_prefs, +) +from adapters.inbound.cli.ui.output import print_plain_line + + +def _prompt_choice(label: str, *, default: str = "1") -> int: + while True: + choice = typer.prompt(label, default=default).strip() + try: + return int(choice) + except ValueError: + print_plain_line("Enter a number from the list.", as_json=False) + + +def _prompt_project( + projects: list[dict[str, Any]], +) -> dict[str, Any]: + if not projects: + raise GitLabReadError("No GitLab projects found for this account.") + print_plain_line("Select a project:", as_json=False) + for index, p in enumerate(projects, start=1): + print_plain_line( + f" {index}. {p.get('path_with_namespace')}\t{p.get('name')}", + as_json=False, + ) + while True: + selected = _prompt_choice("Project number") + if 1 <= selected <= len(projects): + return projects[selected - 1] + print_plain_line("Enter a number from the list.", as_json=False) + + +def run_gitlab_select_flow( + *, + instance_host: str | None = None, + project_path: str | None = None, + limit: int = 10, +) -> dict[str, Any]: + """Pick a GitLab project, persist choice, and fetch sample MRs + issues.""" + if not sys.stdin.isatty() and not project_path: + raise GitLabReadError( + "Interactive project selection requires a terminal. " + "Use: potpie gitlab select --project group/repo" + ) + creds = load_gitlab_read_credentials(instance_host=instance_host) + prefs = creds.get("workspaces") if isinstance(creds.get("workspaces"), dict) else {} + projects = fetch_gitlab_projects(instance_host=instance_host, limit=100) + + default_project = str( + project_path or prefs.get("default_project") or "" + ).strip() + + if project_path or (default_project and not sys.stdin.isatty()): + match = next( + (p for p in projects if p.get("path_with_namespace") == default_project), + None, + ) + picked = match or { + "path_with_namespace": default_project, + "name": default_project, + "id": None, + } + else: + picked = _prompt_project(projects) + + path_with_ns = str(picked.get("path_with_namespace") or "").strip() + project_id = picked.get("id") + + if project_id is None: + project_id = quote_plus(path_with_ns) + + mrs = fetch_gitlab_merge_requests( + project_id, instance_host=instance_host, limit=limit, + ) + issues = fetch_gitlab_issues( + project_id, instance_host=instance_host, limit=limit, + ) + + save_gitlab_workspace_prefs( + instance_host=instance_host, + default_project=path_with_ns, + ) + return { + "product": "gitlab", + "workspace_key": path_with_ns, + "workspace_name": picked.get("name"), + "merge_requests": mrs, + "issues": issues, + } diff --git a/potpie/context-engine/adapters/outbound/cli_auth/credentials.py b/potpie/context-engine/adapters/outbound/cli_auth/credentials.py index 6ec367da9..ee61818a9 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/credentials.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/credentials.py @@ -135,5 +135,37 @@ def save_jira_workspace_prefs(self, *, project_key: str) -> None: def save_confluence_workspace_prefs(self, *, space_key: str) -> None: _store.save_confluence_workspace_prefs(space_key=space_key) + # --- GitLab credentials + workspace prefs ---------------------------- + def get_gitlab_credentials( + self, instance_host: str | None = None, + ) -> dict[str, Any]: + return _store.get_gitlab_credentials(instance_host=instance_host) + + def save_gitlab_credentials( + self, + credentials: dict[str, Any], + *, + account: dict[str, Any] | None = None, + ) -> None: + _store.save_gitlab_credentials(credentials, account=account) + + def clear_gitlab_credentials( + self, instance_host: str | None = None, + ) -> None: + _store.clear_gitlab_credentials(instance_host=instance_host) + + def list_gitlab_instances(self) -> list[dict[str, Any]]: + return _store.list_gitlab_instances() + + def save_gitlab_workspace_prefs( + self, + *, + instance_host: str | None = None, + default_project: str, + ) -> None: + _store.save_gitlab_workspace_prefs( + instance_host=instance_host, default_project=default_project, + ) + __all__ = ["CredentialStore", "FileCredentialStore"] diff --git a/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py b/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py index a2e803b83..307c96d96 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py @@ -30,6 +30,7 @@ _ATLASSIAN_CREDENTIALS_KEY = "atlassian" _JIRA_CREDENTIALS_KEY = "jira" _CONFLUENCE_CREDENTIALS_KEY = "confluence" +_GITLAB_CREDENTIALS_KEY = "gitlab" class CredentialStoreError(CliAuthError): @@ -1095,6 +1096,163 @@ def save_linear_workspace_prefs( ) +def _gitlab_pat_secret(host: str) -> str: + return f"gitlab_pat_{host}" + + +def _norm_gitlab_host(host: str) -> str: + key = str(host or "").strip().lower().rstrip("/") + if not key: + return "gitlab.com" + if key.startswith(("http://", "https://")): + from urllib.parse import urlparse + + parsed = urlparse(key) + return parsed.netloc or "gitlab.com" + return key + + +def _read_gitlab_metadata() -> dict[str, Any]: + return _read_metadata_entry(_GITLAB_CREDENTIALS_KEY) or {} + + +def _get_active_gitlab_host() -> str | None: + meta = _read_gitlab_metadata() + active = str(meta.get("active_instance_host") or "").strip() + if active: + return active + instances = meta.get("instances") + if isinstance(instances, dict) and len(instances) == 1: + return next(iter(instances.keys())) + return None + + +def save_gitlab_credentials( + credentials: dict[str, Any], + *, + account: dict[str, Any] | None = None, +) -> None: + """Store GitLab PAT credentials for a single instance.""" + from adapters.outbound.cli_auth.integration_profile import ( + build_gitlab_integration_record, + ) + + inst_host = _norm_gitlab_host( + credentials.get("instance_host") or credentials.get("instance_url") or "" + ) + pat = str(credentials.get("personal_access_token") or "").strip() + if not pat: + raise ProviderCredentialError("GitLab personal access token is required.") + + secret_name = _gitlab_pat_secret(inst_host) + token_storage = _store_file_secret("GitLab PAT", secret_name, pat) + + instance_entry = dict(credentials) + instance_entry.pop("personal_access_token", None) + instance_entry["instance_host"] = inst_host + instance_entry["token_storage"] = token_storage + + record = build_gitlab_integration_record( + instance_entry, + account=account, + ) + + meta = _read_gitlab_metadata() + instances = dict(meta.get("instances") or {}) + instances[inst_host] = record + meta["instances"] = instances + meta["active_instance_host"] = inst_host + _write_metadata_entry(_GITLAB_CREDENTIALS_KEY, meta) + + +def get_gitlab_credentials( + instance_host: str | None = None, +) -> dict[str, Any]: + """Return GitLab credentials (metadata + PAT) for an instance.""" + meta = _read_gitlab_metadata() + host = _norm_gitlab_host(instance_host) if instance_host else _get_active_gitlab_host() + if not host: + return {} + + instances = meta.get("instances") + if not isinstance(instances, dict): + return {} + entry = instances.get(host) + if not isinstance(entry, dict): + return {} + + secret_name = _gitlab_pat_secret(host) + pat = _load_file_secret("GitLab PAT", secret_name) + if not pat: + return {} + result = dict(entry) + result["personal_access_token"] = pat + return result + + +def clear_gitlab_credentials(instance_host: str | None = None) -> None: + """Remove stored GitLab credentials for an instance (or all instances).""" + meta = _read_gitlab_metadata() + instances = dict(meta.get("instances") or {}) + + if instance_host: + host = _norm_gitlab_host(instance_host) + _delete_file_secret("GitLab PAT", _gitlab_pat_secret(host)) + instances.pop(host, None) + if instances: + meta["instances"] = instances + active = meta.get("active_instance_host") + if active == host: + meta["active_instance_host"] = next(iter(instances.keys())) + _write_metadata_entry(_GITLAB_CREDENTIALS_KEY, meta) + else: + _clear_metadata_entries(_GITLAB_CREDENTIALS_KEY) + else: + for host in list(instances.keys()): + _delete_file_secret("GitLab PAT", _gitlab_pat_secret(host)) + _clear_metadata_entries(_GITLAB_CREDENTIALS_KEY) + + +def list_gitlab_instances() -> list[dict[str, Any]]: + """Return connected GitLab instances with metadata only.""" + meta = _read_gitlab_metadata() + instances = meta.get("instances") + if not isinstance(instances, dict): + return [] + active = str(meta.get("active_instance_host") or "").strip() + rows: list[dict[str, Any]] = [] + for host, entry in instances.items(): + if not isinstance(entry, dict): + continue + row = dict(entry) + row["instance_host"] = host + row["active"] = host == active + rows.append(row) + return rows + + +def save_gitlab_workspace_prefs( + *, + instance_host: str | None = None, + default_project: str, +) -> None: + """Persist the user's default GitLab project selection.""" + meta = _read_gitlab_metadata() + host = _norm_gitlab_host(instance_host) if instance_host else _get_active_gitlab_host() + if not host: + raise ProviderCredentialError( + "GitLab is not connected. Run: potpie gitlab login" + ) + instances = dict(meta.get("instances") or {}) + entry = dict(instances.get(host) or {}) + workspaces = dict(entry.get("workspaces") or {}) + workspaces["default_project"] = default_project.strip() + entry["workspaces"] = workspaces + instances[host] = entry + meta["instances"] = instances + _write_metadata_entry(_GITLAB_CREDENTIALS_KEY, meta) + + def save_atlassian_workspace_prefs( *, jira_project: str | None = None, @@ -1123,6 +1281,9 @@ def get_integration_tokens(provider: str) -> dict[str, Any]: else: creds = get_confluence_credentials() return {"auth_type": "api_token", **creds} if creds else {} + if key == _GITLAB_CREDENTIALS_KEY: + creds = get_gitlab_credentials() + return {"auth_type": "personal_access_token", **creds} if creds else {} raise ValueError(f"Unknown integration provider {provider!r}.") @@ -1156,6 +1317,9 @@ def clear_integration_tokens(provider: str) -> None: if key == _ATLASSIAN_CREDENTIALS_KEY: clear_atlassian_credentials() return + if key == _GITLAB_CREDENTIALS_KEY: + clear_gitlab_credentials() + return raise ValueError(f"Unknown integration provider {provider!r}.") @@ -1167,6 +1331,7 @@ def list_integration_providers() -> list[str]: _JIRA_CREDENTIALS_KEY, _CONFLUENCE_CREDENTIALS_KEY, _ATLASSIAN_CREDENTIALS_KEY, + _GITLAB_CREDENTIALS_KEY, ): if isinstance(integrations.get(key), dict): found.append(key) @@ -1284,6 +1449,44 @@ def get_integration_status(provider: str) -> dict[str, Any]: "token_storage": (entry or creds).get("token_storage"), } + if key == _GITLAB_CREDENTIALS_KEY: + from adapters.outbound.cli_auth.integration_profile import ( + gitlab_account_from_entry, + ) + + creds = get_gitlab_credentials() + if not creds: + return { + "provider": key, + "authenticated": False, + "auth_type": "personal_access_token", + } + meta = _read_gitlab_metadata() + instances = meta.get("instances") + active_host = _get_active_gitlab_host() + entry = ( + instances.get(active_host) + if isinstance(instances, dict) and active_host + else {} + ) or {} + account = gitlab_account_from_entry(entry) + instance_count = ( + len(instances) if isinstance(instances, dict) else 0 + ) + return { + "provider": key, + "authenticated": True, + "auth_type": "personal_access_token", + "login": account.get("username") or account.get("name"), + "email": account.get("email"), + "instance_url": entry.get("instance_url"), + "instance_host": active_host, + "stored_at": entry.get("stored_at"), + "token_storage": entry.get("token_storage"), + "provider_host": active_host, + "instance_count": instance_count, + } + raise ValueError(f"Unknown integration provider {provider!r}.") diff --git a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py new file mode 100644 index 000000000..7c0924e59 --- /dev/null +++ b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py @@ -0,0 +1,151 @@ +"""Outbound GitLab client: PAT verification + instance URL normalization. + +Pure HTTP/transport for GitLab (no CLI/presentation). The interactive +login command lives in ``adapters.inbound.cli.auth.gitlab_auth``. +""" + +from __future__ import annotations + +import enum +from typing import Any +from urllib.parse import urlparse + +from adapters.outbound.cli_auth.http import AuthHttpClient, AuthHttpError, HttpClient +from adapters.outbound.cli_auth.provider_config import ( + GITLAB_DEFAULT_INSTANCE, + gitlab_api_base_url, +) + + +_HTTP_TIMEOUT = 30.0 + + +class GitLabAuthErrorKind(enum.StrEnum): + INVALID_CREDENTIALS = "invalid_credentials" + INSUFFICIENT_SCOPES = "insufficient_scopes" + INSTANCE_UNREACHABLE = "instance_unreachable" + UNKNOWN = "unknown" + + +def normalize_instance_url(url: str) -> str: + """Normalize a GitLab instance URL to ``https://host[:port]``.""" + value = url.strip().rstrip("/") + if not value: + return "" + if not value.startswith(("http://", "https://")): + value = f"https://{value}" + parsed = urlparse(value) + if not parsed.netloc: + return "" + scheme = parsed.scheme or "https" + return f"{scheme}://{parsed.netloc}" + + +def instance_host(instance_url: str) -> str: + """Extract the host (with optional port) from a normalized instance URL.""" + normalized = normalize_instance_url(instance_url) + if not normalized: + return "" + parsed = urlparse(normalized) + return parsed.netloc or "" + + +def gitlab_auth_headers(pat: str) -> dict[str, str]: + return { + "PRIVATE-TOKEN": pat.strip(), + "Accept": "application/json", + } + + +def _classify_status(status: int) -> GitLabAuthErrorKind: + if status == 401: + return GitLabAuthErrorKind.INVALID_CREDENTIALS + if status == 403: + return GitLabAuthErrorKind.INSUFFICIENT_SCOPES + return GitLabAuthErrorKind.UNKNOWN + + +def verify_instance_access( + instance_url: str, + pat: str, + *, + http: HttpClient | None = None, +) -> tuple[bool, GitLabAuthErrorKind | None, dict[str, Any]]: + """Verify PAT against ``GET /api/v4/user``. + + Returns ``(ok, error_kind, user_data)``. + """ + normalized = normalize_instance_url(instance_url) or GITLAB_DEFAULT_INSTANCE + api_base = gitlab_api_base_url(normalized) + headers = gitlab_auth_headers(pat) + + owns = http is None + http = http or AuthHttpClient(timeout=_HTTP_TIMEOUT) + try: + try: + response = http.get(f"{api_base}/user", headers=headers) + except AuthHttpError: + return False, GitLabAuthErrorKind.INSTANCE_UNREACHABLE, {} + if response.status_code != 200: + return False, _classify_status(response.status_code), {} + try: + data = response.json() + except ValueError: + return False, GitLabAuthErrorKind.UNKNOWN, {} + if not isinstance(data, dict): + return False, GitLabAuthErrorKind.UNKNOWN, {} + return True, None, data + finally: + if owns: + http.close() + + +def verify_read_api_scope( + instance_url: str, + pat: str, + *, + http: HttpClient | None = None, +) -> tuple[bool, GitLabAuthErrorKind | None]: + """Confirm the PAT has at least ``read_api`` scope by listing one project.""" + normalized = normalize_instance_url(instance_url) or GITLAB_DEFAULT_INSTANCE + api_base = gitlab_api_base_url(normalized) + headers = gitlab_auth_headers(pat) + + owns = http is None + http = http or AuthHttpClient(timeout=_HTTP_TIMEOUT) + try: + try: + response = http.get( + f"{api_base}/projects", + headers=headers, + params={"membership": "true", "per_page": "1"}, + ) + except AuthHttpError: + return False, GitLabAuthErrorKind.INSTANCE_UNREACHABLE + if response.status_code == 200: + return True, None + return False, _classify_status(response.status_code) + finally: + if owns: + http.close() + + +def parse_user_profile(data: dict[str, Any]) -> dict[str, Any]: + """Extract account metadata from a ``/api/v4/user`` response.""" + account: dict[str, Any] = {} + user_id = data.get("id") + if user_id is not None: + account["id"] = str(user_id) + username = data.get("username") + if username: + account["username"] = str(username) + name = data.get("name") + if name: + account["name"] = str(name) + email = data.get("email") + if email: + account["email"] = str(email) + web_url = data.get("web_url") + if web_url: + account["web_url"] = str(web_url) + return account diff --git a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py new file mode 100644 index 000000000..275a37140 --- /dev/null +++ b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py @@ -0,0 +1,212 @@ +"""GitLab read client: projects, merge requests, and issues. + +Data-fetching layer for the GitLab CLI integration. The interactive +picker flow lives in ``adapters.inbound.cli.auth.gitlab_read``. +""" + +from __future__ import annotations + +from typing import Any + +from adapters.outbound.cli_auth.http import AuthHttpClient, AuthHttpError, HttpClient +from adapters.outbound.cli_auth.gitlab_client import ( + gitlab_auth_headers, + normalize_instance_url, +) +from adapters.outbound.cli_auth.credentials_store import ( + ProviderCredentialError, + get_gitlab_credentials, +) +from adapters.outbound.cli_auth.provider_config import ( + GITLAB_DEFAULT_INSTANCE, + gitlab_api_base_url, +) + + +_HTTP_TIMEOUT = 30.0 + + +class GitLabReadError(Exception): + """Non-transport failure in a GitLab read operation.""" + + +def load_gitlab_read_credentials( + instance_host: str | None = None, +) -> dict[str, Any]: + """Load stored GitLab credentials for API reads.""" + creds = get_gitlab_credentials(instance_host=instance_host) + if not creds: + raise GitLabReadError( + "GitLab is not connected. Run: potpie gitlab login" + ) + pat = str(creds.get("personal_access_token") or "").strip() + if not pat: + raise GitLabReadError( + "GitLab token not found in local credentials. " + "Run: potpie gitlab login" + ) + return creds + + +def _api_base(creds: dict[str, Any]) -> str: + instance_url = str(creds.get("instance_url") or "").strip() + normalized = normalize_instance_url(instance_url) if instance_url else GITLAB_DEFAULT_INSTANCE + return gitlab_api_base_url(normalized) + + +def _headers(creds: dict[str, Any]) -> dict[str, str]: + pat = str(creds.get("personal_access_token") or "").strip() + return gitlab_auth_headers(pat) + + +def _get_json( + url: str, + headers: dict[str, str], + *, + params: dict[str, str] | None = None, + http: HttpClient | None = None, +) -> Any: + owns = http is None + http = http or AuthHttpClient(timeout=_HTTP_TIMEOUT) + try: + try: + response = http.get(url, headers=headers, params=params) + except AuthHttpError as exc: + raise GitLabReadError(f"GitLab API request failed: {exc}") from exc + if response.status_code == 401: + raise GitLabReadError( + "GitLab token expired or revoked. Run: potpie gitlab login" + ) + if response.status_code == 403: + raise GitLabReadError( + "GitLab token lacks required scopes (need read_api). " + "Run: potpie gitlab login --force" + ) + if response.status_code != 200: + raise GitLabReadError( + f"GitLab API returned HTTP {response.status_code}" + ) + try: + return response.json() + except ValueError as exc: + raise GitLabReadError("GitLab API returned non-JSON response") from exc + finally: + if owns: + http.close() + + +def fetch_gitlab_projects( + *, + instance_host: str | None = None, + limit: int = 100, +) -> list[dict[str, Any]]: + """List projects accessible to the authenticated user.""" + creds = load_gitlab_read_credentials(instance_host=instance_host) + api_base = _api_base(creds) + headers = _headers(creds) + data = _get_json( + f"{api_base}/projects", + headers, + params={ + "membership": "true", + "order_by": "last_activity_at", + "sort": "desc", + "per_page": str(min(limit, 100)), + }, + ) + if not isinstance(data, list): + return [] + rows: list[dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + continue + rows.append({ + "id": item.get("id"), + "path_with_namespace": item.get("path_with_namespace"), + "name": item.get("name"), + "visibility": item.get("visibility"), + "web_url": item.get("web_url"), + "default_branch": item.get("default_branch"), + }) + return rows + + +def fetch_gitlab_merge_requests( + project_id: int | str, + *, + instance_host: str | None = None, + state: str = "opened", + limit: int = 10, +) -> list[dict[str, Any]]: + """Fetch merge requests for a project.""" + creds = load_gitlab_read_credentials(instance_host=instance_host) + api_base = _api_base(creds) + headers = _headers(creds) + data = _get_json( + f"{api_base}/projects/{project_id}/merge_requests", + headers, + params={ + "state": state, + "order_by": "updated_at", + "sort": "desc", + "per_page": str(min(limit, 100)), + }, + ) + if not isinstance(data, list): + return [] + rows: list[dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + continue + author = item.get("author") + rows.append({ + "iid": item.get("iid"), + "title": item.get("title"), + "state": item.get("state"), + "author": author.get("username") if isinstance(author, dict) else None, + "target_branch": item.get("target_branch"), + "source_branch": item.get("source_branch"), + "web_url": item.get("web_url"), + "updated_at": item.get("updated_at"), + }) + return rows + + +def fetch_gitlab_issues( + project_id: int | str, + *, + instance_host: str | None = None, + state: str = "opened", + limit: int = 10, +) -> list[dict[str, Any]]: + """Fetch issues for a project.""" + creds = load_gitlab_read_credentials(instance_host=instance_host) + api_base = _api_base(creds) + headers = _headers(creds) + data = _get_json( + f"{api_base}/projects/{project_id}/issues", + headers, + params={ + "state": state, + "order_by": "updated_at", + "sort": "desc", + "per_page": str(min(limit, 100)), + }, + ) + if not isinstance(data, list): + return [] + rows: list[dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + continue + assignee = item.get("assignee") + rows.append({ + "iid": item.get("iid"), + "title": item.get("title"), + "state": item.get("state"), + "assignee": assignee.get("username") if isinstance(assignee, dict) else None, + "labels": item.get("labels"), + "web_url": item.get("web_url"), + "updated_at": item.get("updated_at"), + }) + return rows diff --git a/potpie/context-engine/adapters/outbound/cli_auth/integration_profile.py b/potpie/context-engine/adapters/outbound/cli_auth/integration_profile.py index 2d62475d1..23bcc69dc 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/integration_profile.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/integration_profile.py @@ -300,6 +300,43 @@ def atlassian_account_from_entry(entry: dict[str, Any]) -> dict[str, Any]: return {} +def build_gitlab_integration_record( + credentials: dict[str, Any], + *, + account: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build non-secret GitLab metadata for credentials.json.""" + now = utc_now_iso() + inst_url = str(credentials.get("instance_url") or "").strip() + inst_host = str(credentials.get("instance_host") or "").strip() + + record: dict[str, Any] = { + "provider": "gitlab", + "provider_host": inst_host, + "auth_type": "personal_access_token", + "token_storage": "file", + "stored_at": _stored_at_from_credentials(credentials), + "created_at": credentials.get("created_at") or now, + "updated_at": now, + "instance_url": inst_url, + "instance_host": inst_host, + "metadata": {"auth_flow": "personal_access_token"}, + } + if account: + record["account"] = dict(account) + workspaces = credentials.get("workspaces") + if isinstance(workspaces, dict) and workspaces: + record["workspaces"] = workspaces + return record + + +def gitlab_account_from_entry(entry: dict[str, Any]) -> dict[str, Any]: + account = entry.get("account") + if isinstance(account, dict): + return account + return {} + + def atlassian_site_from_entry(entry: dict[str, Any]) -> dict[str, Any]: site = entry.get("site") if isinstance(site, dict): diff --git a/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py b/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py index c9a89fb72..062796a85 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py @@ -49,6 +49,8 @@ def verify_integration_access( return False, f"jira: {jira_message}; confluence: {confluence_message}" if provider in ("jira", "confluence"): return _verify_atlassian_product(provider, credentials) + if provider == "gitlab": + return _verify_gitlab(credentials, http=http) return False, f"unknown provider {provider!r}" @@ -113,6 +115,42 @@ def _verify_github( return True, f"ok ({login})" +def _verify_gitlab( + credentials: dict[str, Any], + *, + http: HttpClient | None = None, +) -> tuple[bool, str]: + from adapters.outbound.cli_auth.gitlab_client import ( + GitLabAuthErrorKind, + verify_instance_access, + parse_user_profile, + ) + + pat = str(credentials.get("personal_access_token") or "").strip() + if not pat: + return False, "not authenticated" + instance_url = str( + credentials.get("instance_url") or "https://gitlab.com" + ).strip() + + ok, error_kind, user_data = verify_instance_access( + instance_url, pat, http=http, + ) + if not ok: + if error_kind == GitLabAuthErrorKind.INVALID_CREDENTIALS: + return False, "invalid GitLab personal access token" + if error_kind == GitLabAuthErrorKind.INSUFFICIENT_SCOPES: + return False, "GitLab token missing required scopes (need read_api)" + if error_kind == GitLabAuthErrorKind.INSTANCE_UNREACHABLE: + return False, f"GitLab instance unreachable: {instance_url}" + return False, "GitLab verification failed" + + account = parse_user_profile(user_data) + username = account.get("username") or account.get("name") or "user" + inst_host = credentials.get("instance_host") or instance_url + return True, f"ok ({username} @ {inst_host})" + + def _verify_message_for_kind( provider: Provider, kind: AtlassianAuthErrorKind | None, diff --git a/potpie/context-engine/adapters/outbound/cli_auth/provider_config.py b/potpie/context-engine/adapters/outbound/cli_auth/provider_config.py index 33b6b495a..1aa576b9b 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/provider_config.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/provider_config.py @@ -9,7 +9,7 @@ from bootstrap.runtime_settings import load_runtime_settings -Provider = Literal["linear", "github", "atlassian", "jira", "confluence"] +Provider = Literal["linear", "github", "atlassian", "jira", "confluence", "gitlab"] OAuthProvider = Literal["linear"] AtlassianProduct = Literal["jira", "confluence"] @@ -121,6 +121,22 @@ def token_url(provider: OAuthProvider) -> str: return LINEAR_TOKEN_URL +GITLAB_DEFAULT_INSTANCE = "https://gitlab.com" +GITLAB_PAT_PAGE_PATH = "/-/user_settings/personal_access_tokens" +GITLAB_API_VERSION = "v4" +GITLAB_RECOMMENDED_SCOPES = ("read_api",) + + +def gitlab_pat_page_url(instance_url: str) -> str: + base = instance_url.rstrip("/") or GITLAB_DEFAULT_INSTANCE + return f"{base}{GITLAB_PAT_PAGE_PATH}" + + +def gitlab_api_base_url(instance_url: str) -> str: + base = instance_url.rstrip("/") or GITLAB_DEFAULT_INSTANCE + return f"{base}/api/{GITLAB_API_VERSION}" + + def atlassian_jira_gateway_url(cloud_id: str) -> str: return f"{ATLASSIAN_API_GATEWAY}/ex/jira/{cloud_id.strip()}" diff --git a/potpie/context-engine/domain/ports/cli_auth/credentials.py b/potpie/context-engine/domain/ports/cli_auth/credentials.py index 9724a5333..d6755a571 100644 --- a/potpie/context-engine/domain/ports/cli_auth/credentials.py +++ b/potpie/context-engine/domain/ports/cli_auth/credentials.py @@ -99,5 +99,30 @@ def save_jira_workspace_prefs(self, *, project_key: str) -> None: ... def save_confluence_workspace_prefs(self, *, space_key: str) -> None: ... + # --- GitLab credentials + workspace prefs ---------------------------- + def get_gitlab_credentials( + self, instance_host: str | None = None, + ) -> dict[str, Any]: ... + + def save_gitlab_credentials( + self, + credentials: dict[str, Any], + *, + account: dict[str, Any] | None = None, + ) -> None: ... + + def clear_gitlab_credentials( + self, instance_host: str | None = None, + ) -> None: ... + + def list_gitlab_instances(self) -> list[dict[str, Any]]: ... + + def save_gitlab_workspace_prefs( + self, + *, + instance_host: str | None = None, + default_project: str, + ) -> None: ... + __all__ = ["CredentialStore"] diff --git a/potpie/context-engine/tests/_auth_fakes.py b/potpie/context-engine/tests/_auth_fakes.py index 736e4222a..b3c1e9328 100644 --- a/potpie/context-engine/tests/_auth_fakes.py +++ b/potpie/context-engine/tests/_auth_fakes.py @@ -212,5 +212,74 @@ def save_jira_workspace_prefs(self, *, project_key: str) -> None: def save_confluence_workspace_prefs(self, *, space_key: str) -> None: self.workspace_prefs["confluence"] = {"space_key": space_key} + # --- GitLab credentials + workspace prefs ---------------------------- + def get_gitlab_credentials( + self, instance_host: str | None = None, + ) -> dict[str, Any]: + gitlab = self.providers.get("gitlab", {}) + if not gitlab: + return {} + instances = gitlab.get("instances", {}) + host = instance_host or gitlab.get("active_instance_host") + if host and isinstance(instances, dict): + return dict(instances.get(host, {})) + return {} + + def save_gitlab_credentials( + self, + credentials: dict[str, Any], + *, + account: dict[str, Any] | None = None, + ) -> None: + gitlab = dict(self.providers.get("gitlab", {})) + instances = dict(gitlab.get("instances", {})) + host = credentials.get("instance_host") or "gitlab.com" + entry = dict(credentials) + entry.pop("personal_access_token", None) + if account: + entry["account"] = account + instances[host] = entry + gitlab["instances"] = instances + gitlab["active_instance_host"] = host + self.providers["gitlab"] = gitlab + + def clear_gitlab_credentials( + self, instance_host: str | None = None, + ) -> None: + if instance_host: + gitlab = dict(self.providers.get("gitlab", {})) + instances = dict(gitlab.get("instances", {})) + instances.pop(instance_host, None) + if instances: + gitlab["instances"] = instances + self.providers["gitlab"] = gitlab + else: + self.providers.pop("gitlab", None) + else: + self.providers.pop("gitlab", None) + + def list_gitlab_instances(self) -> list[dict[str, Any]]: + gitlab = self.providers.get("gitlab", {}) + instances = gitlab.get("instances", {}) + if not isinstance(instances, dict): + return [] + active = str(gitlab.get("active_instance_host") or "") + return [ + {**entry, "instance_host": host, "active": host == active} + for host, entry in instances.items() + if isinstance(entry, dict) + ] + + def save_gitlab_workspace_prefs( + self, + *, + instance_host: str | None = None, + default_project: str, + ) -> None: + self.workspace_prefs["gitlab"] = { + "instance_host": instance_host, + "default_project": default_project, + } + __all__ = ["FakeAuthHttpClient", "InMemoryCredentialStore"] diff --git a/potpie/context-engine/tests/unit/test_cli_gitlab.py b/potpie/context-engine/tests/unit/test_cli_gitlab.py new file mode 100644 index 000000000..841e0ee6d --- /dev/null +++ b/potpie/context-engine/tests/unit/test_cli_gitlab.py @@ -0,0 +1,389 @@ +"""Unit tests for GitLab CLI auth commands and credential store integration.""" + +from __future__ import annotations + +import time +from typing import Any +from unittest.mock import patch + +import httpx +import pytest +import typer + +from adapters.outbound.cli_auth import credentials_store as cs +from adapters.outbound.cli_auth.gitlab_client import ( + GitLabAuthErrorKind, + normalize_instance_url, + parse_user_profile, +) +from adapters.outbound.cli_auth.gitlab_read_client import ( + GitLabReadError, +) +from tests._auth_fakes import FakeAuthHttpClient + +pytestmark = pytest.mark.unit + + +# ─── credential store round-trip ─────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _isolate_creds(tmp_path, monkeypatch): + """Redirect credential files to a temp directory.""" + monkeypatch.setattr(cs, "config_dir", lambda: tmp_path) + monkeypatch.setattr(cs, "credentials_path", lambda: tmp_path / "credentials.json") + monkeypatch.setattr( + cs, "integration_secrets_path", lambda: tmp_path / "integration_secrets.json", + ) + + +def test_save_and_get_gitlab_credentials() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.corp.com", + "instance_host": "gitlab.corp.com", + "personal_access_token": "glpat-abc123", + "stored_at": 1710000000.0, + }, + account={"id": "42", "username": "jane"}, + ) + + creds = cs.get_gitlab_credentials() + assert creds["personal_access_token"] == "glpat-abc123" + assert creds.get("instance_host") == "gitlab.corp.com" or creds.get("provider_host") == "gitlab.corp.com" + + +def test_save_gitlab_credentials_requires_pat() -> None: + with pytest.raises(cs.ProviderCredentialError, match="personal access token"): + cs.save_gitlab_credentials( + {"instance_url": "https://gitlab.com", "personal_access_token": ""}, + ) + + +def test_get_gitlab_credentials_empty_when_not_stored() -> None: + assert cs.get_gitlab_credentials() == {} + + +def test_clear_gitlab_credentials() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-abc", + }, + ) + assert cs.get_gitlab_credentials() != {} + cs.clear_gitlab_credentials() + assert cs.get_gitlab_credentials() == {} + + +def test_multi_instance_save_and_list() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-saas", + }, + account={"username": "alice"}, + ) + cs.save_gitlab_credentials( + { + "instance_url": "https://git.corp.com", + "instance_host": "git.corp.com", + "personal_access_token": "glpat-corp", + }, + account={"username": "alice-corp"}, + ) + instances = cs.list_gitlab_instances() + assert len(instances) == 2 + hosts = {i["instance_host"] for i in instances} + assert "gitlab.com" in hosts + assert "git.corp.com" in hosts + + active_count = sum(1 for i in instances if i.get("active")) + assert active_count == 1 + + +def test_clear_specific_instance() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-saas", + }, + ) + cs.save_gitlab_credentials( + { + "instance_url": "https://git.corp.com", + "instance_host": "git.corp.com", + "personal_access_token": "glpat-corp", + }, + ) + cs.clear_gitlab_credentials(instance_host="gitlab.com") + instances = cs.list_gitlab_instances() + assert len(instances) == 1 + assert instances[0]["instance_host"] == "git.corp.com" + + +def test_get_gitlab_credentials_for_specific_host() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-saas", + }, + ) + cs.save_gitlab_credentials( + { + "instance_url": "https://git.corp.com", + "instance_host": "git.corp.com", + "personal_access_token": "glpat-corp", + }, + ) + creds = cs.get_gitlab_credentials(instance_host="gitlab.com") + assert creds["personal_access_token"] == "glpat-saas" + + +def test_save_gitlab_workspace_prefs() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-abc", + }, + ) + cs.save_gitlab_workspace_prefs(default_project="acme/api") + creds = cs.get_gitlab_credentials() + workspaces = creds.get("workspaces") or {} + assert workspaces.get("default_project") == "acme/api" + + +def test_save_gitlab_workspace_prefs_not_connected() -> None: + with pytest.raises(cs.ProviderCredentialError, match="not connected"): + cs.save_gitlab_workspace_prefs(default_project="acme/api") + + +# ─── integration_status for gitlab ──────────────────────────────────────── + + +def test_get_integration_status_gitlab_not_authenticated() -> None: + status = cs.get_integration_status("gitlab") + assert status["provider"] == "gitlab" + assert status["authenticated"] is False + assert status["auth_type"] == "personal_access_token" + + +def test_get_integration_status_gitlab_authenticated() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.corp.com", + "instance_host": "gitlab.corp.com", + "personal_access_token": "glpat-abc", + "stored_at": 1710000000.0, + }, + account={"username": "jane", "email": "jane@corp.com"}, + ) + status = cs.get_integration_status("gitlab") + assert status["provider"] == "gitlab" + assert status["authenticated"] is True + assert status["auth_type"] == "personal_access_token" + assert status["login"] == "jane" + assert status["instance_host"] == "gitlab.corp.com" + + +# ─── get_integration_tokens for gitlab ──────────────────────────────────── + + +def test_get_integration_tokens_gitlab() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-tok", + }, + ) + tokens = cs.get_integration_tokens("gitlab") + assert tokens["auth_type"] == "personal_access_token" + assert tokens["personal_access_token"] == "glpat-tok" + + +def test_get_integration_tokens_gitlab_empty() -> None: + tokens = cs.get_integration_tokens("gitlab") + assert tokens == {} + + +# ─── clear_integration_tokens for gitlab ────────────────────────────────── + + +def test_clear_integration_tokens_gitlab() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-tok", + }, + ) + cs.clear_integration_tokens("gitlab") + assert cs.get_gitlab_credentials() == {} + + +# ─── list_integration_providers includes gitlab ─────────────────────────── + + +def test_list_integration_providers_includes_gitlab() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-tok", + }, + ) + providers = cs.list_integration_providers() + assert "gitlab" in providers + + +# ─── gitlab_read_client credential loading ──────────────────────────────── + + +def test_load_gitlab_read_credentials_raises_when_not_connected() -> None: + from adapters.outbound.cli_auth.gitlab_read_client import load_gitlab_read_credentials + + with pytest.raises(GitLabReadError, match="not connected"): + load_gitlab_read_credentials() + + +def test_load_gitlab_read_credentials_raises_when_no_token() -> None: + from adapters.outbound.cli_auth.gitlab_read_client import load_gitlab_read_credentials + + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-tok", + }, + ) + # Simulate token missing by clearing the secret but keeping metadata + cs._delete_file_secret("GitLab PAT", cs._gitlab_pat_secret("gitlab.com")) + with pytest.raises(GitLabReadError, match="not connected"): + load_gitlab_read_credentials() + + +# ─── _norm_gitlab_host ──────────────────────────────────────────────────── + + +def test_norm_gitlab_host_bare() -> None: + assert cs._norm_gitlab_host("gitlab.corp.com") == "gitlab.corp.com" + + +def test_norm_gitlab_host_url() -> None: + assert cs._norm_gitlab_host("https://gitlab.corp.com") == "gitlab.corp.com" + + +def test_norm_gitlab_host_empty_defaults() -> None: + assert cs._norm_gitlab_host("") == "gitlab.com" + + +# ─── GitLab login browser countdown UX ───────────────────────────────────── + + +def test_wait_for_enter_or_auto_open_returns_when_enter_is_pressed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + import io + + from adapters.inbound.cli.auth import gitlab_auth as gl_auth + + input_stream = io.StringIO("\n") + monkeypatch.setattr( + gl_auth.select, + "select", + lambda _read, _write, _error, _timeout: ([input_stream], [], []), + ) + monkeypatch.setattr( + gl_auth.time, + "sleep", + lambda _seconds: pytest.fail("enter should skip the countdown sleep"), + ) + + gl_auth._wait_for_enter_or_auto_open(seconds=10, input_stream=input_stream) + + out = capsys.readouterr().out + assert "Press Enter to open now, or browser opens in 10s" in out + + +def test_wait_for_enter_or_auto_open_times_out_on_same_line( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + import io + + from adapters.inbound.cli.auth import gitlab_auth as gl_auth + + monkeypatch.setattr( + gl_auth.select, + "select", + lambda _read, _write, _error, _timeout: ([], [], []), + ) + + gl_auth._wait_for_enter_or_auto_open(seconds=2, input_stream=io.StringIO("")) + + out = capsys.readouterr().out + assert "\r\033[KPress Enter to open now, or browser opens in 2s" in out + assert "\r\033[KPress Enter to open now, or browser opens in 1s" in out + + +def test_open_gitlab_pat_page_ctrl_c_exits_cleanly( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from adapters.inbound.cli.auth import gitlab_auth as gl_auth + + monkeypatch.setattr( + gl_auth.webbrowser, + "open", + lambda _url: pytest.fail("cancelled login must not open the browser"), + ) + + def _cancel() -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(gl_auth, "_wait_for_enter_or_auto_open", _cancel) + + with pytest.raises(typer.Exit) as exc: + gl_auth._open_gitlab_pat_page("https://gitlab.com") + assert exc.value.exit_code == gl_auth.EXIT_CANCELLED + assert "\nGitLab login cancelled." in capsys.readouterr().out + + +def test_run_gitlab_pat_auth_opens_browser_after_countdown( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + from adapters.inbound.cli.auth import gitlab_auth as gl_auth + + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.setattr(gl_auth.sys.stdin, "isatty", lambda: True) + opened: list[str] = [] + monkeypatch.setattr( + gl_auth.webbrowser, + "open", + lambda url, **_kwargs: opened.append(url) or True, + ) + monkeypatch.setattr(gl_auth, "_wait_for_enter_or_auto_open", lambda: None) + monkeypatch.setattr(gl_auth, "_prompt_instance_url", lambda default="": "gitlab.com") + monkeypatch.setattr(gl_auth, "_prompt_pat", lambda: "glpat-test") + monkeypatch.setattr( + gl_auth, + "verify_instance_access", + lambda *_a, **_k: (True, None, {"id": 1, "username": "jane"}), + ) + monkeypatch.setattr( + gl_auth, + "verify_read_api_scope", + lambda *_a, **_k: (True, None), + ) + + gl_auth.run_gitlab_pat_auth(force=True) + + assert opened == ["https://gitlab.com/-/user_settings/personal_access_tokens"] diff --git a/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py b/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py new file mode 100644 index 000000000..0dfda53b9 --- /dev/null +++ b/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py @@ -0,0 +1,650 @@ +"""CLI command, auth flow, read client, and read flow coverage for GitLab.""" + +from __future__ import annotations + +import io +import types +from typing import Any + +import httpx +import pytest +import typer +from typer.testing import CliRunner + +from adapters.inbound.cli import host_cli as cli_main +from adapters.inbound.cli.auth import gitlab_auth as gl_auth +from adapters.inbound.cli.auth import gitlab_commands as gl_cmds +from adapters.inbound.cli.auth import gitlab_read as gl_read +from adapters.outbound.cli_auth import credentials_store as cs +from adapters.outbound.cli_auth.gitlab_client import GitLabAuthErrorKind +from adapters.outbound.cli_auth.gitlab_read_client import ( + GitLabReadError, + fetch_gitlab_issues, + fetch_gitlab_merge_requests, + fetch_gitlab_projects, +) +from adapters.outbound.cli_auth.http import AuthHttpError +from tests._auth_fakes import FakeAuthHttpClient + +pytestmark = pytest.mark.unit + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _isolate_creds(tmp_path, monkeypatch): + monkeypatch.setattr(cs, "config_dir", lambda: tmp_path) + monkeypatch.setattr(cs, "credentials_path", lambda: tmp_path / "credentials.json") + monkeypatch.setattr( + cs, "integration_secrets_path", lambda: tmp_path / "integration_secrets.json", + ) + + +def _save_gitlab(*, host: str = "gitlab.com", token: str = "glpat-test") -> None: + cs.save_gitlab_credentials( + { + "instance_url": f"https://{host}", + "instance_host": host, + "personal_access_token": token, + }, + account={"username": "jane", "id": "42"}, + ) + + +def _patch_read_http(monkeypatch: pytest.MonkeyPatch, responses: list[httpx.Response]) -> None: + from adapters.outbound.cli_auth import gitlab_read_client as gl_read_client + + fake = FakeAuthHttpClient(responses) + monkeypatch.setattr( + gl_read_client, + "AuthHttpClient", + lambda **_kwargs: fake, + ) + + +# ─── gitlab_auth helpers ──────────────────────────────────────────────────── + + +def test_auth_failure_message_all_kinds() -> None: + base = gl_auth._auth_failure_message(None, "https://gitlab.com") + assert "Could not authenticate" in base + invalid = gl_auth._auth_failure_message( + GitLabAuthErrorKind.INVALID_CREDENTIALS, "https://gitlab.com", + ) + assert "Invalid personal access token" in invalid + scopes = gl_auth._auth_failure_message( + GitLabAuthErrorKind.INSUFFICIENT_SCOPES, "https://gitlab.com", + ) + assert "missing required scopes" in scopes + unreachable = gl_auth._auth_failure_message( + GitLabAuthErrorKind.INSTANCE_UNREACHABLE, "https://git.corp.com", + ) + assert "Cannot reach" in unreachable + + +def test_detect_gitlab_from_git_remote(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "adapters.inbound.cli.repo_location.current_git_remote", + lambda _cwd: "gitlab.corp.com/acme/api", + ) + assert gl_auth._detect_gitlab_from_git_remote() == "https://gitlab.corp.com" + + +def test_detect_gitlab_from_git_remote_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "adapters.inbound.cli.repo_location.current_git_remote", + lambda _cwd: "github.com/acme/api", + ) + assert gl_auth._detect_gitlab_from_git_remote() is None + + +def test_guard_typer_prompt_maps_abort_to_keyboard_interrupt() -> None: + import click + + def _abort() -> None: + raise click.Abort() + + with pytest.raises(KeyboardInterrupt): + gl_auth._guard_typer_prompt(_abort) + + +def test_open_gitlab_pat_page_browser_open_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(gl_auth, "_wait_for_enter_or_auto_open", lambda: None) + monkeypatch.setattr(gl_auth.webbrowser, "open", lambda *_a, **_k: False) + + gl_auth._open_gitlab_pat_page("https://gitlab.com") + + out = capsys.readouterr().out + assert "Could not open a browser" in out + assert "personal_access_tokens" in out + + +def test_run_gitlab_pat_auth_already_connected(tmp_path, capsys) -> None: + _save_gitlab() + gl_auth.run_gitlab_pat_auth() + out = capsys.readouterr().out + assert "already connected" in out + + +def test_run_gitlab_pat_auth_non_tty_requires_flags(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gl_auth.sys.stdin, "isatty", lambda: False) + with pytest.raises(typer.Exit) as exc: + gl_auth.run_gitlab_pat_auth(force=True) + assert exc.value.exit_code == 1 + + +def test_run_gitlab_pat_auth_supplied_credentials_success( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr( + gl_auth, + "verify_instance_access", + lambda *_a, **_k: (True, None, {"id": 1, "username": "jane"}), + ) + monkeypatch.setattr( + gl_auth, + "verify_read_api_scope", + lambda *_a, **_k: (True, None), + ) + gl_auth.run_gitlab_pat_auth( + force=True, + instance="https://gitlab.corp.com", + token="glpat-abc", + ) + assert "Connected GitLab" in capsys.readouterr().out + + +def test_run_gitlab_pat_auth_verify_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + gl_auth, + "verify_instance_access", + lambda *_a, **_k: (False, GitLabAuthErrorKind.INVALID_CREDENTIALS, {}), + ) + with pytest.raises(typer.Exit) as exc: + gl_auth.run_gitlab_pat_auth( + force=True, + instance="https://gitlab.com", + token="bad", + ) + assert exc.value.exit_code == gl_auth.EXIT_AUTH + + +def test_run_gitlab_pat_auth_scope_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + gl_auth, + "verify_instance_access", + lambda *_a, **_k: (True, None, {"id": 1, "username": "jane"}), + ) + monkeypatch.setattr( + gl_auth, + "verify_read_api_scope", + lambda *_a, **_k: (False, GitLabAuthErrorKind.INSUFFICIENT_SCOPES), + ) + with pytest.raises(typer.Exit) as exc: + gl_auth.run_gitlab_pat_auth( + force=True, + instance="https://gitlab.com", + token="glpat-abc", + ) + assert exc.value.exit_code == gl_auth.EXIT_AUTH + + +def test_run_gitlab_pat_auth_storage_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + gl_auth, + "verify_instance_access", + lambda *_a, **_k: (True, None, {"id": 1, "username": "jane"}), + ) + monkeypatch.setattr( + gl_auth, + "verify_read_api_scope", + lambda *_a, **_k: (True, None), + ) + monkeypatch.setattr( + gl_auth, + "save_gitlab_credentials", + lambda *_a, **_k: (_ for _ in ()).throw( + cs.ProviderCredentialError("disk full") + ), + ) + with pytest.raises(typer.Exit) as exc: + gl_auth.run_gitlab_pat_auth( + force=True, + instance="https://gitlab.com", + token="glpat-abc", + ) + assert exc.value.exit_code == gl_auth.EXIT_AUTH + + +def test_wait_for_enter_or_auto_open_select_error_path( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + sleeps: list[float] = [] + + def _bad_select(*_args, **_kwargs): + raise OSError("select failed") + + monkeypatch.setattr(gl_auth.select, "select", _bad_select) + monkeypatch.setattr(gl_auth.time, "sleep", lambda s: sleeps.append(s)) + + gl_auth._wait_for_enter_or_auto_open(seconds=1, input_stream=io.StringIO("")) + + assert sleeps == [1] + assert "browser opens in 1s" in capsys.readouterr().out + + +# ─── gitlab_read_client ───────────────────────────────────────────────────── + + +def test_fetch_gitlab_projects_success(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + _patch_read_http( + monkeypatch, + [ + httpx.Response( + 200, + json=[ + { + "id": 7, + "path_with_namespace": "acme/api", + "name": "API", + "visibility": "private", + "web_url": "https://gitlab.com/acme/api", + "default_branch": "main", + }, + "skip-me", + ], + ), + ], + ) + rows = fetch_gitlab_projects() + assert len(rows) == 1 + assert rows[0]["path_with_namespace"] == "acme/api" + + +def test_fetch_gitlab_projects_non_list_response(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + _patch_read_http(monkeypatch, [httpx.Response(200, json={"error": "nope"})]) + assert fetch_gitlab_projects() == [] + + +def test_fetch_gitlab_merge_requests_success(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + _patch_read_http( + monkeypatch, + [ + httpx.Response( + 200, + json=[ + { + "iid": 3, + "title": "Fix bug", + "state": "opened", + "author": {"username": "jane"}, + "target_branch": "main", + "source_branch": "fix", + "web_url": "https://gitlab.com/acme/api/-/merge_requests/3", + "updated_at": "2026-01-01", + } + ], + ), + ], + ) + rows = fetch_gitlab_merge_requests(7) + assert rows[0]["iid"] == 3 + assert rows[0]["author"] == "jane" + + +def test_fetch_gitlab_issues_success(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + _patch_read_http( + monkeypatch, + [ + httpx.Response( + 200, + json=[ + { + "iid": 9, + "title": "Bug", + "state": "opened", + "assignee": {"username": "bob"}, + "labels": ["bug"], + "web_url": "https://gitlab.com/acme/api/-/issues/9", + "updated_at": "2026-01-02", + } + ], + ), + ], + ) + rows = fetch_gitlab_issues(7) + assert rows[0]["iid"] == 9 + assert rows[0]["assignee"] == "bob" + + +def test_get_json_error_paths(monkeypatch: pytest.MonkeyPatch) -> None: + from adapters.outbound.cli_auth import gitlab_read_client as gl_read_client + + _save_gitlab() + + def _raise(*_a, **_k): + raise AuthHttpError("connection refused") + + fake = types.SimpleNamespace(get=_raise, close=lambda: None) + monkeypatch.setattr(gl_read_client, "AuthHttpClient", lambda **_k: fake) + with pytest.raises(GitLabReadError, match="request failed"): + fetch_gitlab_projects() + + _patch_read_http(monkeypatch, [httpx.Response(401)]) + with pytest.raises(GitLabReadError, match="expired or revoked"): + fetch_gitlab_projects() + + _patch_read_http(monkeypatch, [httpx.Response(403)]) + with pytest.raises(GitLabReadError, match="required scopes"): + fetch_gitlab_projects() + + _patch_read_http(monkeypatch, [httpx.Response(500)]) + with pytest.raises(GitLabReadError, match="HTTP 500"): + fetch_gitlab_projects() + + _patch_read_http(monkeypatch, [httpx.Response(200, content=b"not-json")]) + with pytest.raises(GitLabReadError, match="non-JSON"): + fetch_gitlab_projects() + + +# ─── gitlab_read flow ─────────────────────────────────────────────────────── + + +def test_run_gitlab_select_flow_with_project_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _save_gitlab() + monkeypatch.setattr(gl_read.sys.stdin, "isatty", lambda: False) + monkeypatch.setattr( + gl_read, + "fetch_gitlab_projects", + lambda **_k: [{"id": 7, "path_with_namespace": "acme/api", "name": "API"}], + ) + monkeypatch.setattr( + gl_read, + "fetch_gitlab_merge_requests", + lambda *_a, **_k: [{"iid": 1, "title": "MR"}], + ) + monkeypatch.setattr( + gl_read, + "fetch_gitlab_issues", + lambda *_a, **_k: [{"iid": 2, "title": "Issue"}], + ) + result = gl_read.run_gitlab_select_flow(project_path="acme/api", limit=5) + assert result["workspace_key"] == "acme/api" + assert len(result["merge_requests"]) == 1 + assert len(result["issues"]) == 1 + + +def test_run_gitlab_select_flow_requires_terminal_without_project( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(gl_read.sys.stdin, "isatty", lambda: False) + with pytest.raises(GitLabReadError, match="requires a terminal"): + gl_read.run_gitlab_select_flow() + + +def test_prompt_project_invalid_then_valid( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + prompts = iter(["nope", "1"]) + + monkeypatch.setattr(gl_read.typer, "prompt", lambda *_a, **_k: next(prompts)) + picked = gl_read._prompt_project( + [{"path_with_namespace": "acme/api", "name": "API"}], + ) + assert picked["path_with_namespace"] == "acme/api" + assert "Enter a number" in capsys.readouterr().out + + +# ─── gitlab_commands via CliRunner ────────────────────────────────────────── + + +def test_gitlab_login_cli_invokes_auth(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, Any] = {} + + def _fake_auth(**kwargs: Any) -> None: + called.update(kwargs) + + monkeypatch.setattr(gl_cmds, "run_gitlab_pat_auth", _fake_auth) + result = runner.invoke(cli_main.app, ["gitlab", "login", "--force"]) + assert result.exit_code == 0, result.stdout + assert called.get("force") is True + + +def test_gitlab_logout_cli_success() -> None: + _save_gitlab() + result = runner.invoke(cli_main.app, ["gitlab", "logout"]) + assert result.exit_code == 0, result.stdout + assert cs.get_gitlab_credentials() == {} + + +def test_gitlab_logout_cli_json_when_not_authenticated() -> None: + result = runner.invoke(cli_main.app, ["--json", "gitlab", "logout"]) + assert result.exit_code == 0, result.stdout + assert '"cleared_stale": true' in result.stdout + + +def test_gitlab_ls_cli_lists_instances() -> None: + _save_gitlab() + result = runner.invoke(cli_main.app, ["gitlab", "ls"]) + assert result.exit_code == 0, result.stdout + assert "gitlab.com" in result.stdout + + +def test_gitlab_ls_cli_json() -> None: + _save_gitlab() + result = runner.invoke(cli_main.app, ["--json", "gitlab", "ls"]) + assert result.exit_code == 0, result.stdout + assert '"instances"' in result.stdout + + +def test_gitlab_ls_cli_empty() -> None: + result = runner.invoke(cli_main.app, ["gitlab", "ls"]) + assert result.exit_code == 0, result.stdout + assert "none" in result.stdout.lower() + + +def test_gitlab_repos_cli_lists_projects(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + monkeypatch.setattr( + gl_cmds, + "fetch_gitlab_projects", + lambda **_k: [ + {"path_with_namespace": "acme/api", "visibility": "private"}, + ], + ) + result = runner.invoke(cli_main.app, ["gitlab", "repos"]) + assert result.exit_code == 0, result.stdout + assert "acme/api" in result.stdout + + +def test_gitlab_repos_cli_not_connected() -> None: + result = runner.invoke(cli_main.app, ["gitlab", "repos"]) + assert result.exit_code == gl_cmds.EXIT_UNAVAILABLE + + +def test_gitlab_select_cli_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + gl_cmds, + "run_gitlab_select_flow", + lambda **_k: { + "product": "gitlab", + "workspace_key": "acme/api", + "workspace_name": "API", + "merge_requests": [{"iid": 1, "title": "MR", "author": "jane"}], + "issues": [ + { + "iid": 2, + "title": "Bug", + "assignee": "bob", + "labels": ["bug"], + "web_url": "https://gitlab.com/acme/api/-/issues/2", + } + ], + }, + ) + result = runner.invoke( + cli_main.app, + ["--json", "gitlab", "select", "--project", "acme/api"], + ) + assert result.exit_code == 0, result.stdout + assert '"merge_requests"' in result.stdout + + +def test_prompt_instance_url_defaults_to_gitlab_com(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gl_auth.typer, "prompt", lambda *_a, **_k: "gitlab.com") + assert gl_auth._prompt_instance_url() == "https://gitlab.com" + + +def test_prompt_pat_rejects_empty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gl_auth.typer, "prompt", lambda *_a, **_k: " ") + with pytest.raises(typer.Exit) as exc: + gl_auth._prompt_pat() + assert exc.value.exit_code == 1 + + +def test_gitlab_logout_cli_with_instance_json() -> None: + _save_gitlab() + result = runner.invoke( + cli_main.app, + ["--json", "gitlab", "logout", "--instance", "gitlab.com"], + ) + assert result.exit_code == 0, result.stdout + assert '"instance": "gitlab.com"' in result.stdout + + +def test_gitlab_logout_cli_stale_plain_message() -> None: + result = runner.invoke(cli_main.app, ["gitlab", "logout"]) + assert result.exit_code == 0, result.stdout + assert "No active session" in result.stdout + + +def test_gitlab_logout_cli_error(monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(**_kwargs: Any) -> None: + raise cs.ProviderCredentialError("locked") + + monkeypatch.setattr(gl_cmds, "clear_gitlab_credentials", _boom) + result = runner.invoke(cli_main.app, ["gitlab", "logout"]) + assert result.exit_code == gl_cmds.EXIT_AUTH + + +def test_gitlab_ls_cli_error(monkeypatch: pytest.MonkeyPatch) -> None: + def _boom() -> list[dict[str, Any]]: + raise cs.CredentialStoreError("read failed") + + monkeypatch.setattr(gl_cmds, "list_gitlab_instances", _boom) + result = runner.invoke(cli_main.app, ["gitlab", "ls"]) + assert result.exit_code == gl_cmds.EXIT_UNAVAILABLE + + +def test_gitlab_ls_cli_shows_active_instance() -> None: + _save_gitlab() + result = runner.invoke(cli_main.app, ["gitlab", "ls"]) + assert result.exit_code == 0, result.stdout + assert "(active)" in result.stdout + assert "(jane)" in result.stdout + + +def test_gitlab_repos_cli_json_empty(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + monkeypatch.setattr(gl_cmds, "fetch_gitlab_projects", lambda **_k: []) + result = runner.invoke(cli_main.app, ["--json", "gitlab", "repos"]) + assert result.exit_code == 0, result.stdout + assert '"count": 0' in result.stdout + + +def test_gitlab_repos_cli_plain_empty(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + monkeypatch.setattr(gl_cmds, "fetch_gitlab_projects", lambda **_k: []) + result = runner.invoke(cli_main.app, ["gitlab", "repos"]) + assert result.exit_code == 0, result.stdout + assert "(none)" in result.stdout + + +def test_gitlab_repos_cli_read_error(monkeypatch: pytest.MonkeyPatch) -> None: + _save_gitlab() + + def _fail(**_kwargs: Any) -> list[dict[str, Any]]: + raise GitLabReadError("token expired") + + monkeypatch.setattr(gl_cmds, "fetch_gitlab_projects", _fail) + result = runner.invoke(cli_main.app, ["gitlab", "repos"]) + assert result.exit_code == gl_cmds.EXIT_UNAVAILABLE + + +def test_gitlab_select_cli_read_error(monkeypatch: pytest.MonkeyPatch) -> None: + def _fail(**_kwargs: Any) -> dict[str, Any]: + raise GitLabReadError("project missing") + + monkeypatch.setattr(gl_cmds, "run_gitlab_select_flow", _fail) + result = runner.invoke(cli_main.app, ["gitlab", "select", "--project", "acme/api"]) + assert result.exit_code == gl_cmds.EXIT_UNAVAILABLE + + +def test_gitlab_select_cli_plain_output(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + gl_cmds, + "run_gitlab_select_flow", + lambda **_k: { + "workspace_key": "acme/api", + "workspace_name": "API", + "merge_requests": [ + { + "iid": 1, + "title": "MR", + "author": "jane", + "source_branch": "feat", + "target_branch": "main", + "web_url": "https://gitlab.com/acme/api/-/merge_requests/1", + } + ], + "issues": [], + }, + ) + result = runner.invoke(cli_main.app, ["gitlab", "select", "--project", "acme/api"]) + assert result.exit_code == 0, result.stdout + assert "Open merge requests" in result.stdout + assert "No open issues" in result.stdout + + +def test_gitlab_select_cli_plain_with_issues(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + gl_cmds, + "run_gitlab_select_flow", + lambda **_k: { + "workspace_key": "acme/api", + "workspace_name": "API", + "merge_requests": [], + "issues": [ + { + "iid": 5, + "title": "Crash", + "assignee": "alice", + "labels": ["bug", "p1"], + "web_url": "https://gitlab.com/acme/api/-/issues/5", + } + ], + }, + ) + result = runner.invoke(cli_main.app, ["gitlab", "select", "--project", "acme/api"]) + assert result.exit_code == 0, result.stdout + assert "Open issues" in result.stdout + assert "Labels: bug, p1" in result.stdout + assert "Assignee: alice" in result.stdout diff --git a/potpie/context-engine/tests/unit/test_gitlab_client.py b/potpie/context-engine/tests/unit/test_gitlab_client.py new file mode 100644 index 000000000..1c6070cad --- /dev/null +++ b/potpie/context-engine/tests/unit/test_gitlab_client.py @@ -0,0 +1,280 @@ +"""Unit tests for the outbound GitLab client modules.""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from adapters.outbound.cli_auth.gitlab_client import ( + GitLabAuthErrorKind, + gitlab_auth_headers, + instance_host, + normalize_instance_url, + parse_user_profile, + verify_instance_access, + verify_read_api_scope, +) +from adapters.outbound.cli_auth.integration_profile import ( + build_gitlab_integration_record, + gitlab_account_from_entry, +) +from adapters.outbound.cli_auth.integration_verify import verify_integration_access +from adapters.outbound.cli_auth.provider_config import ( + GITLAB_DEFAULT_INSTANCE, + gitlab_api_base_url, + gitlab_pat_page_url, +) +from tests._auth_fakes import FakeAuthHttpClient + +pytestmark = pytest.mark.unit + + +# --- normalize_instance_url --- + + +def test_normalize_bare_hostname() -> None: + assert normalize_instance_url("gitlab.corp.com") == "https://gitlab.corp.com" + + +def test_normalize_with_scheme() -> None: + assert normalize_instance_url("http://git.local:8080") == "http://git.local:8080" + + +def test_normalize_strips_trailing_slash() -> None: + assert normalize_instance_url("https://gitlab.com/") == "https://gitlab.com" + + +def test_normalize_empty_returns_empty() -> None: + assert normalize_instance_url("") == "" + + +def test_normalize_preserves_port() -> None: + assert ( + normalize_instance_url("https://git.corp.com:8443") + == "https://git.corp.com:8443" + ) + + +# --- instance_host --- + + +def test_instance_host_extracts_netloc() -> None: + assert instance_host("https://gitlab.corp.com") == "gitlab.corp.com" + + +def test_instance_host_with_port() -> None: + assert instance_host("https://git.local:8443") == "git.local:8443" + + +def test_instance_host_empty() -> None: + assert instance_host("") == "" + + +# --- gitlab_auth_headers --- + + +def test_auth_headers_include_private_token() -> None: + headers = gitlab_auth_headers("glpat-abc123") + assert headers["PRIVATE-TOKEN"] == "glpat-abc123" + assert headers["Accept"] == "application/json" + + +def test_auth_headers_strip_whitespace() -> None: + headers = gitlab_auth_headers(" glpat-abc123 ") + assert headers["PRIVATE-TOKEN"] == "glpat-abc123" + + +# --- provider_config constants --- + + +def test_gitlab_pat_page_url_default() -> None: + url = gitlab_pat_page_url(GITLAB_DEFAULT_INSTANCE) + assert url == "https://gitlab.com/-/user_settings/personal_access_tokens" + + +def test_gitlab_pat_page_url_custom() -> None: + url = gitlab_pat_page_url("https://git.corp.com") + assert url == "https://git.corp.com/-/user_settings/personal_access_tokens" + + +def test_gitlab_api_base_url_default() -> None: + assert gitlab_api_base_url(GITLAB_DEFAULT_INSTANCE) == "https://gitlab.com/api/v4" + + +def test_gitlab_api_base_url_custom() -> None: + assert ( + gitlab_api_base_url("https://git.corp.com:8443") + == "https://git.corp.com:8443/api/v4" + ) + + +# --- parse_user_profile --- + + +def test_parse_user_profile_full() -> None: + data = { + "id": 42, + "username": "jane", + "name": "Jane Doe", + "email": "jane@corp.com", + "web_url": "https://gitlab.corp.com/jane", + } + account = parse_user_profile(data) + assert account["id"] == "42" + assert account["username"] == "jane" + assert account["name"] == "Jane Doe" + assert account["email"] == "jane@corp.com" + assert account["web_url"] == "https://gitlab.corp.com/jane" + + +def test_parse_user_profile_minimal() -> None: + account = parse_user_profile({"id": 1, "username": "bot"}) + assert account["id"] == "1" + assert account["username"] == "bot" + assert "email" not in account + assert "name" not in account + + +# --- verify_instance_access --- + + +def test_verify_instance_access_success() -> None: + fake = FakeAuthHttpClient([ + httpx.Response(200, json={"id": 42, "username": "jane"}), + ]) + ok, error_kind, data = verify_instance_access( + "https://gitlab.com", "glpat-test", http=fake, + ) + assert ok is True + assert error_kind is None + assert data["username"] == "jane" + + +def test_verify_instance_access_unauthorized() -> None: + fake = FakeAuthHttpClient([httpx.Response(401)]) + ok, error_kind, data = verify_instance_access( + "https://gitlab.com", "bad-token", http=fake, + ) + assert ok is False + assert error_kind == GitLabAuthErrorKind.INVALID_CREDENTIALS + assert data == {} + + +def test_verify_instance_access_forbidden() -> None: + fake = FakeAuthHttpClient([httpx.Response(403)]) + ok, error_kind, _ = verify_instance_access( + "https://gitlab.com", "scoped-token", http=fake, + ) + assert ok is False + assert error_kind == GitLabAuthErrorKind.INSUFFICIENT_SCOPES + + +def test_verify_instance_access_unreachable() -> None: + from adapters.outbound.cli_auth.http import AuthHttpError + + def _raise(*a, **kw): + raise AuthHttpError("connection refused") + + fake = FakeAuthHttpClient(handler=_raise) + ok, error_kind, _ = verify_instance_access( + "https://gitlab.corp.com", "tok", http=fake, + ) + assert ok is False + assert error_kind == GitLabAuthErrorKind.INSTANCE_UNREACHABLE + + +# --- verify_read_api_scope --- + + +def test_verify_read_api_scope_success() -> None: + fake = FakeAuthHttpClient([httpx.Response(200, json=[])]) + ok, error = verify_read_api_scope("https://gitlab.com", "glpat-test", http=fake) + assert ok is True + assert error is None + + +def test_verify_read_api_scope_forbidden() -> None: + fake = FakeAuthHttpClient([httpx.Response(403)]) + ok, error = verify_read_api_scope("https://gitlab.com", "glpat-test", http=fake) + assert ok is False + assert error == GitLabAuthErrorKind.INSUFFICIENT_SCOPES + + +# --- build_gitlab_integration_record --- + + +def test_build_gitlab_integration_record() -> None: + creds: dict[str, Any] = { + "instance_url": "https://gitlab.corp.com", + "instance_host": "gitlab.corp.com", + "stored_at": 1710000000.0, + } + account: dict[str, Any] = {"id": "42", "username": "jane", "name": "Jane Doe"} + record = build_gitlab_integration_record(creds, account=account) + assert record["provider"] == "gitlab" + assert record["provider_host"] == "gitlab.corp.com" + assert record["auth_type"] == "personal_access_token" + assert record["instance_url"] == "https://gitlab.corp.com" + assert record["account"]["username"] == "jane" + assert record["metadata"]["auth_flow"] == "personal_access_token" + + +def test_build_gitlab_integration_record_preserves_workspaces() -> None: + creds: dict[str, Any] = { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "workspaces": {"default_project": "acme/api"}, + } + record = build_gitlab_integration_record(creds) + assert record["workspaces"]["default_project"] == "acme/api" + + +# --- gitlab_account_from_entry --- + + +def test_gitlab_account_from_entry_returns_account() -> None: + entry = {"account": {"username": "jane", "id": "42"}} + assert gitlab_account_from_entry(entry) == {"username": "jane", "id": "42"} + + +def test_gitlab_account_from_entry_empty() -> None: + assert gitlab_account_from_entry({}) == {} + + +# --- verify_integration_access for gitlab --- + + +def test_verify_integration_access_gitlab_ok() -> None: + fake = FakeAuthHttpClient([ + httpx.Response(200, json={"id": 42, "username": "jane", "name": "Jane"}), + ]) + ok, msg = verify_integration_access( + "gitlab", + { + "personal_access_token": "glpat-test", + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + }, + http=fake, + ) + assert ok is True + assert "jane" in msg + + +def test_verify_integration_access_gitlab_no_token() -> None: + ok, msg = verify_integration_access("gitlab", {}) + assert ok is False + assert "not authenticated" in msg + + +def test_verify_integration_access_gitlab_unauthorized() -> None: + fake = FakeAuthHttpClient([httpx.Response(401)]) + ok, msg = verify_integration_access( + "gitlab", + {"personal_access_token": "bad", "instance_url": "https://gitlab.com"}, + http=fake, + ) + assert ok is False + assert "invalid" in msg.lower() From 77d300af30e19135f5e802e814b47791859d4a80 Mon Sep 17 00:00:00 2001 From: jdyaberi-pp Date: Mon, 6 Jul 2026 21:46:40 +0530 Subject: [PATCH 2/4] Fix GitLab CLI review findings for logout, auth, and credential flows. Address CodeRabbit feedback by hardening logout routing, preserving workspace prefs on re-login, and aligning verify/select behavior with login expectations. Co-authored-by: Cursor --- .../inbound/cli/auth/auth_commands.py | 6 +- .../adapters/inbound/cli/auth/gitlab_auth.py | 24 ++-- .../inbound/cli/auth/gitlab_commands.py | 26 +++-- .../adapters/inbound/cli/auth/gitlab_read.py | 17 ++- .../outbound/cli_auth/credentials_store.py | 17 ++- .../outbound/cli_auth/gitlab_client.py | 12 +- .../outbound/cli_auth/gitlab_read_client.py | 104 +++++++++--------- .../outbound/cli_auth/integration_verify.py | 7 ++ potpie/context-engine/tests/_auth_fakes.py | 28 ++++- .../tests/unit/test_cli_gitlab.py | 46 +++++++- .../tests/unit/test_cli_gitlab_commands.py | 26 +++++ .../tests/unit/test_gitlab_client.py | 10 +- 12 files changed, 227 insertions(+), 96 deletions(-) diff --git a/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py b/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py index 3f45ac051..73dc22ff8 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py @@ -501,8 +501,6 @@ def integration_status( credentials = ensure_valid_integration_tokens(provider) elif provider == "github": credentials = get_store().get_provider_credentials("github") - elif provider == "gitlab": - credentials = get_integration_tokens(provider) else: credentials = get_integration_tokens(provider) except (ValueError, RuntimeError, ProviderCredentialError) as exc: @@ -637,9 +635,9 @@ def auth_logout(provider: str) -> None: github_logout_impl() return if key == "gitlab": - from adapters.inbound.cli.auth.gitlab_commands import gitlab_logout + from adapters.inbound.cli.auth.gitlab_commands import gitlab_logout_impl - gitlab_logout() + gitlab_logout_impl() return store = get_store() diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py index 82c6713b2..8a9de9b3a 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py @@ -18,17 +18,6 @@ import click from click.exceptions import Abort -if sys.platform == "win32": - import msvcrt -else: - msvcrt = None - -EXIT_CANCELLED = 130 -GITLAB_AUTO_OPEN_SECONDS = 10 -_GITLAB_OPEN_PROMPT_PREFIX = ( - "Press Enter to open now, or browser opens in " -) - from adapters.outbound.cli_auth.gitlab_client import ( GitLabAuthErrorKind, instance_host, @@ -52,6 +41,17 @@ gitlab_pat_page_url, ) +if sys.platform == "win32": + import msvcrt +else: + msvcrt = None + +EXIT_CANCELLED = 130 +GITLAB_AUTO_OPEN_SECONDS = 10 +_GITLAB_OPEN_PROMPT_PREFIX = ( + "Press Enter to open now, or browser opens in " +) + def _guard_typer_prompt(callback): try: @@ -257,7 +257,7 @@ def run_gitlab_pat_auth( else: git_hint = _detect_gitlab_from_git_remote() inst_url = normalize_instance_url( - _prompt_instance_url(default=git_hint or "") + _prompt_instance_url(default=instance or git_hint or "") ) or GITLAB_DEFAULT_INSTANCE if not as_json: _open_gitlab_pat_page(inst_url) diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py index 0ee91bd38..dc8cdb9a1 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py @@ -75,15 +75,7 @@ def gitlab_login( ) -@gitlab_app.command("logout") -def gitlab_logout( - instance: str | None = typer.Option( - None, - "--instance", - "-i", - help="GitLab instance host to disconnect (default: all).", - ), -) -> None: +def gitlab_logout_impl(instance: str | None = None) -> None: """Remove stored GitLab credentials.""" j, v = _flags() was_authenticated = bool( @@ -113,6 +105,19 @@ def gitlab_logout( ) +@gitlab_app.command("logout") +def gitlab_logout( + instance: str | None = typer.Option( + None, + "--instance", + "-i", + help="GitLab instance host to disconnect (default: all).", + ), +) -> None: + """Remove stored GitLab credentials.""" + gitlab_logout_impl(instance=instance) + + @gitlab_app.command("ls") def gitlab_ls() -> None: """List connected GitLab instances.""" @@ -286,7 +291,8 @@ def _print_issue_row(row: dict[str, Any]) -> None: labels = row.get("labels") or [] if isinstance(labels, list) and labels: print_plain_line( - f" Labels: {', '.join(str(l) for l in labels)}", as_json=False, + f" Labels: {', '.join(_esc(label) for label in labels)}", + as_json=False, ) if row.get("web_url"): print_plain_line(f" URL: {_esc(row.get('web_url'))}", as_json=False) diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py index 4922bfcc4..2569f2ac4 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py @@ -8,7 +8,6 @@ import sys from typing import Any -from urllib.parse import quote_plus import typer @@ -59,19 +58,19 @@ def run_gitlab_select_flow( limit: int = 10, ) -> dict[str, Any]: """Pick a GitLab project, persist choice, and fetch sample MRs + issues.""" - if not sys.stdin.isatty() and not project_path: - raise GitLabReadError( - "Interactive project selection requires a terminal. " - "Use: potpie gitlab select --project group/repo" - ) creds = load_gitlab_read_credentials(instance_host=instance_host) prefs = creds.get("workspaces") if isinstance(creds.get("workspaces"), dict) else {} - projects = fetch_gitlab_projects(instance_host=instance_host, limit=100) - default_project = str( project_path or prefs.get("default_project") or "" ).strip() + if not sys.stdin.isatty() and not project_path and not default_project: + raise GitLabReadError( + "Interactive project selection requires a terminal. " + "Use: potpie gitlab select --project group/repo" + ) + projects = fetch_gitlab_projects(instance_host=instance_host, limit=100) + if project_path or (default_project and not sys.stdin.isatty()): match = next( (p for p in projects if p.get("path_with_namespace") == default_project), @@ -89,7 +88,7 @@ def run_gitlab_select_flow( project_id = picked.get("id") if project_id is None: - project_id = quote_plus(path_with_ns) + project_id = path_with_ns mrs = fetch_gitlab_merge_requests( project_id, instance_host=instance_host, limit=limit, diff --git a/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py b/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py index 307c96d96..78e25e39b 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py @@ -1152,13 +1152,22 @@ def save_gitlab_credentials( instance_entry["instance_host"] = inst_host instance_entry["token_storage"] = token_storage + meta = _read_gitlab_metadata() + existing_instances = dict(meta.get("instances") or {}) + existing_entry = existing_instances.get(inst_host) or {} + if "workspaces" not in instance_entry and isinstance( + existing_entry.get("workspaces"), dict, + ): + instance_entry["workspaces"] = existing_entry["workspaces"] + if "created_at" not in instance_entry and existing_entry.get("created_at"): + instance_entry["created_at"] = existing_entry["created_at"] + record = build_gitlab_integration_record( instance_entry, account=account, ) - meta = _read_gitlab_metadata() - instances = dict(meta.get("instances") or {}) + instances = existing_instances instances[inst_host] = record meta["instances"] = instances meta["active_instance_host"] = inst_host @@ -1244,6 +1253,10 @@ def save_gitlab_workspace_prefs( "GitLab is not connected. Run: potpie gitlab login" ) instances = dict(meta.get("instances") or {}) + if host not in instances: + raise ProviderCredentialError( + "GitLab is not connected. Run: potpie gitlab login" + ) entry = dict(instances.get(host) or {}) workspaces = dict(entry.get("workspaces") or {}) workspaces["default_project"] = default_project.strip() diff --git a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py index 7c0924e59..5e04a4475 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py @@ -7,6 +7,7 @@ from __future__ import annotations import enum +import os from typing import Any from urllib.parse import urlparse @@ -27,7 +28,7 @@ class GitLabAuthErrorKind(enum.StrEnum): UNKNOWN = "unknown" -def normalize_instance_url(url: str) -> str: +def normalize_instance_url(url: str, *, allow_http: bool | None = None) -> str: """Normalize a GitLab instance URL to ``https://host[:port]``.""" value = url.strip().rstrip("/") if not value: @@ -37,6 +38,15 @@ def normalize_instance_url(url: str) -> str: parsed = urlparse(value) if not parsed.netloc: return "" + if parsed.scheme == "http": + if allow_http is None: + allow_http = os.environ.get("POTPIE_GITLAB_ALLOW_HTTP", "").strip().lower() in ( + "1", + "true", + "yes", + ) + if not allow_http: + return f"https://{parsed.netloc}" scheme = parsed.scheme or "https" return f"{scheme}://{parsed.netloc}" diff --git a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py index 275a37140..ee44e5af6 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py @@ -6,7 +6,9 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any +from urllib.parse import quote_plus from adapters.outbound.cli_auth.http import AuthHttpClient, AuthHttpError, HttpClient from adapters.outbound.cli_auth.gitlab_client import ( @@ -95,40 +97,50 @@ def _get_json( http.close() +def _encode_project_id(project_id: int | str) -> int | str: + if isinstance(project_id, str): + return quote_plus(project_id) + return project_id + + +def _fetch_normalized_list( + path: str, + *, + instance_host: str | None, + params: dict[str, str], + mapper: Callable[[dict[str, Any]], dict[str, Any]], +) -> list[dict[str, Any]]: + creds = load_gitlab_read_credentials(instance_host=instance_host) + data = _get_json(f"{_api_base(creds)}{path}", _headers(creds), params=params) + if not isinstance(data, list): + return [] + return [mapper(item) for item in data if isinstance(item, dict)] + + def fetch_gitlab_projects( *, instance_host: str | None = None, limit: int = 100, ) -> list[dict[str, Any]]: """List projects accessible to the authenticated user.""" - creds = load_gitlab_read_credentials(instance_host=instance_host) - api_base = _api_base(creds) - headers = _headers(creds) - data = _get_json( - f"{api_base}/projects", - headers, + return _fetch_normalized_list( + "/projects", + instance_host=instance_host, params={ "membership": "true", "order_by": "last_activity_at", "sort": "desc", "per_page": str(min(limit, 100)), }, - ) - if not isinstance(data, list): - return [] - rows: list[dict[str, Any]] = [] - for item in data: - if not isinstance(item, dict): - continue - rows.append({ + mapper=lambda item: { "id": item.get("id"), "path_with_namespace": item.get("path_with_namespace"), "name": item.get("name"), "visibility": item.get("visibility"), "web_url": item.get("web_url"), "default_branch": item.get("default_branch"), - }) - return rows + }, + ) def fetch_gitlab_merge_requests( @@ -139,37 +151,31 @@ def fetch_gitlab_merge_requests( limit: int = 10, ) -> list[dict[str, Any]]: """Fetch merge requests for a project.""" - creds = load_gitlab_read_credentials(instance_host=instance_host) - api_base = _api_base(creds) - headers = _headers(creds) - data = _get_json( - f"{api_base}/projects/{project_id}/merge_requests", - headers, + encoded_id = _encode_project_id(project_id) + return _fetch_normalized_list( + f"/projects/{encoded_id}/merge_requests", + instance_host=instance_host, params={ "state": state, "order_by": "updated_at", "sort": "desc", "per_page": str(min(limit, 100)), }, - ) - if not isinstance(data, list): - return [] - rows: list[dict[str, Any]] = [] - for item in data: - if not isinstance(item, dict): - continue - author = item.get("author") - rows.append({ + mapper=lambda item: { "iid": item.get("iid"), "title": item.get("title"), "state": item.get("state"), - "author": author.get("username") if isinstance(author, dict) else None, + "author": ( + item.get("author", {}).get("username") + if isinstance(item.get("author"), dict) + else None + ), "target_branch": item.get("target_branch"), "source_branch": item.get("source_branch"), "web_url": item.get("web_url"), "updated_at": item.get("updated_at"), - }) - return rows + }, + ) def fetch_gitlab_issues( @@ -180,33 +186,27 @@ def fetch_gitlab_issues( limit: int = 10, ) -> list[dict[str, Any]]: """Fetch issues for a project.""" - creds = load_gitlab_read_credentials(instance_host=instance_host) - api_base = _api_base(creds) - headers = _headers(creds) - data = _get_json( - f"{api_base}/projects/{project_id}/issues", - headers, + encoded_id = _encode_project_id(project_id) + return _fetch_normalized_list( + f"/projects/{encoded_id}/issues", + instance_host=instance_host, params={ "state": state, "order_by": "updated_at", "sort": "desc", "per_page": str(min(limit, 100)), }, - ) - if not isinstance(data, list): - return [] - rows: list[dict[str, Any]] = [] - for item in data: - if not isinstance(item, dict): - continue - assignee = item.get("assignee") - rows.append({ + mapper=lambda item: { "iid": item.get("iid"), "title": item.get("title"), "state": item.get("state"), - "assignee": assignee.get("username") if isinstance(assignee, dict) else None, + "assignee": ( + item.get("assignee", {}).get("username") + if isinstance(item.get("assignee"), dict) + else None + ), "labels": item.get("labels"), "web_url": item.get("web_url"), "updated_at": item.get("updated_at"), - }) - return rows + }, + ) diff --git a/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py b/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py index 062796a85..9c9d9dd5f 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py @@ -123,6 +123,7 @@ def _verify_gitlab( from adapters.outbound.cli_auth.gitlab_client import ( GitLabAuthErrorKind, verify_instance_access, + verify_read_api_scope, parse_user_profile, ) @@ -145,6 +146,12 @@ def _verify_gitlab( return False, f"GitLab instance unreachable: {instance_url}" return False, "GitLab verification failed" + scope_ok, scope_error = verify_read_api_scope(instance_url, pat, http=http) + if not scope_ok: + if scope_error == GitLabAuthErrorKind.INSUFFICIENT_SCOPES: + return False, "GitLab token missing required scopes (need read_api)" + return False, "GitLab verification failed" + account = parse_user_profile(user_data) username = account.get("username") or account.get("name") or "user" inst_host = credentials.get("instance_host") or instance_url diff --git a/potpie/context-engine/tests/_auth_fakes.py b/potpie/context-engine/tests/_auth_fakes.py index b3c1e9328..35277e983 100644 --- a/potpie/context-engine/tests/_auth_fakes.py +++ b/potpie/context-engine/tests/_auth_fakes.py @@ -14,6 +14,8 @@ import httpx +from adapters.outbound.cli_auth.credentials_store import ProviderCredentialError + class FakeAuthHttpClient: """Scriptable :class:`~adapters.outbound.cli_auth.http.HttpClient` for tests. @@ -231,6 +233,9 @@ def save_gitlab_credentials( *, account: dict[str, Any] | None = None, ) -> None: + pat = str(credentials.get("personal_access_token") or "").strip() + if not pat: + raise ProviderCredentialError("GitLab personal access token is required.") gitlab = dict(self.providers.get("gitlab", {})) instances = dict(gitlab.get("instances", {})) host = credentials.get("instance_host") or "gitlab.com" @@ -238,6 +243,11 @@ def save_gitlab_credentials( entry.pop("personal_access_token", None) if account: entry["account"] = account + existing = instances.get(host) or {} + if "workspaces" not in entry and isinstance(existing.get("workspaces"), dict): + entry["workspaces"] = existing["workspaces"] + if "created_at" not in entry and existing.get("created_at"): + entry["created_at"] = existing["created_at"] instances[host] = entry gitlab["instances"] = instances gitlab["active_instance_host"] = host @@ -276,10 +286,20 @@ def save_gitlab_workspace_prefs( instance_host: str | None = None, default_project: str, ) -> None: - self.workspace_prefs["gitlab"] = { - "instance_host": instance_host, - "default_project": default_project, - } + gitlab = dict(self.providers.get("gitlab", {})) + instances = dict(gitlab.get("instances", {})) + host = instance_host or gitlab.get("active_instance_host") + if not host or host not in instances: + raise ProviderCredentialError( + "GitLab is not connected. Run: potpie gitlab login" + ) + entry = dict(instances.get(host) or {}) + workspaces = dict(entry.get("workspaces") or {}) + workspaces["default_project"] = default_project.strip() + entry["workspaces"] = workspaces + instances[host] = entry + gitlab["instances"] = instances + self.providers["gitlab"] = gitlab __all__ = ["FakeAuthHttpClient", "InMemoryCredentialStore"] diff --git a/potpie/context-engine/tests/unit/test_cli_gitlab.py b/potpie/context-engine/tests/unit/test_cli_gitlab.py index 841e0ee6d..4a235345d 100644 --- a/potpie/context-engine/tests/unit/test_cli_gitlab.py +++ b/potpie/context-engine/tests/unit/test_cli_gitlab.py @@ -50,7 +50,7 @@ def test_save_and_get_gitlab_credentials() -> None: creds = cs.get_gitlab_credentials() assert creds["personal_access_token"] == "glpat-abc123" - assert creds.get("instance_host") == "gitlab.corp.com" or creds.get("provider_host") == "gitlab.corp.com" + assert creds.get("instance_host") == "gitlab.corp.com" def test_save_gitlab_credentials_requires_pat() -> None: @@ -158,6 +158,50 @@ def test_save_gitlab_workspace_prefs() -> None: assert workspaces.get("default_project") == "acme/api" +def test_save_gitlab_credentials_preserves_workspace_prefs_on_relogin() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-old", + "stored_at": 1710000000.0, + }, + ) + cs.save_gitlab_workspace_prefs(default_project="acme/api") + first = cs.get_gitlab_credentials() + created_at = first.get("created_at") + + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-new", + "stored_at": 1710000100.0, + }, + account={"username": "jane"}, + ) + creds = cs.get_gitlab_credentials() + assert creds["personal_access_token"] == "glpat-new" + assert creds.get("workspaces", {}).get("default_project") == "acme/api" + if created_at is not None: + assert creds.get("created_at") == created_at + + +def test_save_gitlab_workspace_prefs_unknown_host() -> None: + cs.save_gitlab_credentials( + { + "instance_url": "https://gitlab.com", + "instance_host": "gitlab.com", + "personal_access_token": "glpat-abc", + }, + ) + with pytest.raises(cs.ProviderCredentialError, match="not connected"): + cs.save_gitlab_workspace_prefs( + instance_host="unknown.corp.com", + default_project="acme/api", + ) + + def test_save_gitlab_workspace_prefs_not_connected() -> None: with pytest.raises(cs.ProviderCredentialError, match="not connected"): cs.save_gitlab_workspace_prefs(default_project="acme/api") diff --git a/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py b/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py index 0dfda53b9..7a2514326 100644 --- a/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py +++ b/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py @@ -391,9 +391,35 @@ def test_run_gitlab_select_flow_with_project_path( assert len(result["issues"]) == 1 +def test_run_gitlab_select_flow_with_saved_default_project( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _save_gitlab() + cs.save_gitlab_workspace_prefs(default_project="acme/api") + monkeypatch.setattr(gl_read.sys.stdin, "isatty", lambda: False) + monkeypatch.setattr( + gl_read, + "fetch_gitlab_projects", + lambda **_k: [{"id": 7, "path_with_namespace": "acme/api", "name": "API"}], + ) + monkeypatch.setattr( + gl_read, + "fetch_gitlab_merge_requests", + lambda *_a, **_k: [{"iid": 1, "title": "MR"}], + ) + monkeypatch.setattr( + gl_read, + "fetch_gitlab_issues", + lambda *_a, **_k: [{"iid": 2, "title": "Issue"}], + ) + result = gl_read.run_gitlab_select_flow(limit=5) + assert result["workspace_key"] == "acme/api" + + def test_run_gitlab_select_flow_requires_terminal_without_project( monkeypatch: pytest.MonkeyPatch, ) -> None: + _save_gitlab() monkeypatch.setattr(gl_read.sys.stdin, "isatty", lambda: False) with pytest.raises(GitLabReadError, match="requires a terminal"): gl_read.run_gitlab_select_flow() diff --git a/potpie/context-engine/tests/unit/test_gitlab_client.py b/potpie/context-engine/tests/unit/test_gitlab_client.py index 1c6070cad..c4837b961 100644 --- a/potpie/context-engine/tests/unit/test_gitlab_client.py +++ b/potpie/context-engine/tests/unit/test_gitlab_client.py @@ -39,7 +39,14 @@ def test_normalize_bare_hostname() -> None: def test_normalize_with_scheme() -> None: - assert normalize_instance_url("http://git.local:8080") == "http://git.local:8080" + assert normalize_instance_url("http://git.local:8080") == "https://git.local:8080" + + +def test_normalize_with_scheme_allow_http() -> None: + assert ( + normalize_instance_url("http://git.local:8080", allow_http=True) + == "http://git.local:8080" + ) def test_normalize_strips_trailing_slash() -> None: @@ -249,6 +256,7 @@ def test_gitlab_account_from_entry_empty() -> None: def test_verify_integration_access_gitlab_ok() -> None: fake = FakeAuthHttpClient([ httpx.Response(200, json={"id": 42, "username": "jane", "name": "Jane"}), + httpx.Response(200, json=[]), ]) ok, msg = verify_integration_access( "gitlab", From d38ceb1cf333206f7c93e615a9594f598c58dcfb Mon Sep 17 00:00:00 2001 From: jdyaberi-pp Date: Mon, 6 Jul 2026 21:56:44 +0530 Subject: [PATCH 3/4] Apply ruff format and remove unused imports for pre-commit CI. Co-authored-by: Cursor --- .../inbound/cli/auth/auth_commands.py | 8 ++++- .../adapters/inbound/cli/auth/gitlab_auth.py | 24 ++++++------- .../inbound/cli/auth/gitlab_commands.py | 17 +++++----- .../adapters/inbound/cli/auth/gitlab_read.py | 12 ++++--- .../outbound/cli_auth/credentials_store.py | 15 ++++---- .../outbound/cli_auth/gitlab_client.py | 4 ++- .../outbound/cli_auth/gitlab_read_client.py | 18 +++++----- .../outbound/cli_auth/integration_verify.py | 8 ++--- potpie/context-engine/tests/_auth_fakes.py | 6 ++-- .../tests/unit/test_cli_gitlab.py | 26 +++++++------- .../tests/unit/test_cli_gitlab_commands.py | 33 +++++++++++++----- .../tests/unit/test_gitlab_client.py | 34 +++++++++++++------ 12 files changed, 121 insertions(+), 84 deletions(-) diff --git a/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py b/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py index 73dc22ff8..24b6acdc2 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/auth_commands.py @@ -83,7 +83,13 @@ confluence_app = typer.Typer(help="Confluence integration and read.") _OAUTH_CALLBACK_TIMEOUT = 300.0 -_ALL_PROVIDERS: tuple[Provider, ...] = ("github", "linear", "jira", "confluence", "gitlab") +_ALL_PROVIDERS: tuple[Provider, ...] = ( + "github", + "linear", + "jira", + "confluence", + "gitlab", +) IntegrationAuthProvider = Literal["linear", "atlassian", "jira", "confluence"] diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py index 8a9de9b3a..50cff74f3 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_auth.py @@ -30,10 +30,9 @@ ProviderCredentialError, credentials_path, get_gitlab_credentials, - get_integration_status, save_gitlab_credentials, ) -from adapters.inbound.cli.commands._common import EXIT_AUTH, get_store +from adapters.inbound.cli.commands._common import EXIT_AUTH from adapters.inbound.cli.ui.output import emit_error, print_plain_line from adapters.outbound.cli_auth.provider_config import ( GITLAB_DEFAULT_INSTANCE, @@ -48,9 +47,7 @@ EXIT_CANCELLED = 130 GITLAB_AUTO_OPEN_SECONDS = 10 -_GITLAB_OPEN_PROMPT_PREFIX = ( - "Press Enter to open now, or browser opens in " -) +_GITLAB_OPEN_PROMPT_PREFIX = "Press Enter to open now, or browser opens in " def _guard_typer_prompt(callback): @@ -225,9 +222,7 @@ def run_gitlab_pat_auth( supplied = bool((instance or "").strip() and (token or "").strip()) if not force: - host_to_check = ( - instance_host(instance) if instance else None - ) + host_to_check = instance_host(instance) if instance else None existing = get_gitlab_credentials(instance_host=host_to_check) if existing.get("personal_access_token"): inst = existing.get("instance_url") or existing.get("instance_host", "") @@ -256,9 +251,12 @@ def run_gitlab_pat_auth( pat = (token or "").strip() else: git_hint = _detect_gitlab_from_git_remote() - inst_url = normalize_instance_url( - _prompt_instance_url(default=instance or git_hint or "") - ) or GITLAB_DEFAULT_INSTANCE + inst_url = ( + normalize_instance_url( + _prompt_instance_url(default=instance or git_hint or "") + ) + or GITLAB_DEFAULT_INSTANCE + ) if not as_json: _open_gitlab_pat_page(inst_url) else: @@ -298,7 +296,9 @@ def run_gitlab_pat_auth( save_gitlab_credentials(payload, account=account) except ProviderCredentialError as exc: emit_error( - "GitLab credential storage failed", str(exc), verbose=verbose, + "GitLab credential storage failed", + str(exc), + verbose=verbose, ) raise typer.Exit(code=EXIT_AUTH) from exc diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py index dc8cdb9a1..b9721fecd 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_commands.py @@ -15,11 +15,10 @@ GitLabReadError, fetch_gitlab_projects, ) -from adapters.inbound.cli.commands._common import EXIT_AUTH, EXIT_UNAVAILABLE, get_store +from adapters.inbound.cli.commands._common import EXIT_AUTH, EXIT_UNAVAILABLE from adapters.outbound.cli_auth.credentials_store import ( CredentialStoreError, ProviderCredentialError, - credentials_path, get_integration_status, list_gitlab_instances, clear_gitlab_credentials, @@ -78,9 +77,7 @@ def gitlab_login( def gitlab_logout_impl(instance: str | None = None) -> None: """Remove stored GitLab credentials.""" j, v = _flags() - was_authenticated = bool( - get_integration_status("gitlab").get("authenticated") - ) + was_authenticated = bool(get_integration_status("gitlab").get("authenticated")) try: clear_gitlab_credentials(instance_host=instance) except (ProviderCredentialError, CredentialStoreError, ValueError) as exc: @@ -136,7 +133,8 @@ def gitlab_ls() -> None: ) if j: print_json_blob( - {"ok": True, "provider": "gitlab", "instances": rows}, as_json=True, + {"ok": True, "provider": "gitlab", "instances": rows}, + as_json=True, ) return print_plain_line("GitLab instances:", as_json=False) @@ -223,7 +221,9 @@ def gitlab_select( j, v = _flags() try: result = run_gitlab_select_flow( - instance_host=instance, project_path=project, limit=limit, + instance_host=instance, + project_path=project, + limit=limit, ) except GitLabReadError as exc: emit_error("GitLab fetch failed", str(exc), verbose=v) @@ -233,8 +233,7 @@ def gitlab_select( command="gitlab select", result_kind="provider_select", item_count=( - len(result.get("merge_requests") or []) - + len(result.get("issues") or []) + len(result.get("merge_requests") or []) + len(result.get("issues") or []) ), provider="gitlab", ) diff --git a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py index 2569f2ac4..ae6692fe9 100644 --- a/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py +++ b/potpie/context-engine/adapters/inbound/cli/auth/gitlab_read.py @@ -60,9 +60,7 @@ def run_gitlab_select_flow( """Pick a GitLab project, persist choice, and fetch sample MRs + issues.""" creds = load_gitlab_read_credentials(instance_host=instance_host) prefs = creds.get("workspaces") if isinstance(creds.get("workspaces"), dict) else {} - default_project = str( - project_path or prefs.get("default_project") or "" - ).strip() + default_project = str(project_path or prefs.get("default_project") or "").strip() if not sys.stdin.isatty() and not project_path and not default_project: raise GitLabReadError( @@ -91,10 +89,14 @@ def run_gitlab_select_flow( project_id = path_with_ns mrs = fetch_gitlab_merge_requests( - project_id, instance_host=instance_host, limit=limit, + project_id, + instance_host=instance_host, + limit=limit, ) issues = fetch_gitlab_issues( - project_id, instance_host=instance_host, limit=limit, + project_id, + instance_host=instance_host, + limit=limit, ) save_gitlab_workspace_prefs( diff --git a/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py b/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py index 78e25e39b..0222f2234 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/credentials_store.py @@ -1156,7 +1156,8 @@ def save_gitlab_credentials( existing_instances = dict(meta.get("instances") or {}) existing_entry = existing_instances.get(inst_host) or {} if "workspaces" not in instance_entry and isinstance( - existing_entry.get("workspaces"), dict, + existing_entry.get("workspaces"), + dict, ): instance_entry["workspaces"] = existing_entry["workspaces"] if "created_at" not in instance_entry and existing_entry.get("created_at"): @@ -1179,7 +1180,9 @@ def get_gitlab_credentials( ) -> dict[str, Any]: """Return GitLab credentials (metadata + PAT) for an instance.""" meta = _read_gitlab_metadata() - host = _norm_gitlab_host(instance_host) if instance_host else _get_active_gitlab_host() + host = ( + _norm_gitlab_host(instance_host) if instance_host else _get_active_gitlab_host() + ) if not host: return {} @@ -1247,7 +1250,9 @@ def save_gitlab_workspace_prefs( ) -> None: """Persist the user's default GitLab project selection.""" meta = _read_gitlab_metadata() - host = _norm_gitlab_host(instance_host) if instance_host else _get_active_gitlab_host() + host = ( + _norm_gitlab_host(instance_host) if instance_host else _get_active_gitlab_host() + ) if not host: raise ProviderCredentialError( "GitLab is not connected. Run: potpie gitlab login" @@ -1483,9 +1488,7 @@ def get_integration_status(provider: str) -> dict[str, Any]: else {} ) or {} account = gitlab_account_from_entry(entry) - instance_count = ( - len(instances) if isinstance(instances, dict) else 0 - ) + instance_count = len(instances) if isinstance(instances, dict) else 0 return { "provider": key, "authenticated": True, diff --git a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py index 5e04a4475..9ce18e36d 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_client.py @@ -40,7 +40,9 @@ def normalize_instance_url(url: str, *, allow_http: bool | None = None) -> str: return "" if parsed.scheme == "http": if allow_http is None: - allow_http = os.environ.get("POTPIE_GITLAB_ALLOW_HTTP", "").strip().lower() in ( + allow_http = os.environ.get( + "POTPIE_GITLAB_ALLOW_HTTP", "" + ).strip().lower() in ( "1", "true", "yes", diff --git a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py index ee44e5af6..137586c64 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/gitlab_read_client.py @@ -16,7 +16,6 @@ normalize_instance_url, ) from adapters.outbound.cli_auth.credentials_store import ( - ProviderCredentialError, get_gitlab_credentials, ) from adapters.outbound.cli_auth.provider_config import ( @@ -38,21 +37,22 @@ def load_gitlab_read_credentials( """Load stored GitLab credentials for API reads.""" creds = get_gitlab_credentials(instance_host=instance_host) if not creds: - raise GitLabReadError( - "GitLab is not connected. Run: potpie gitlab login" - ) + raise GitLabReadError("GitLab is not connected. Run: potpie gitlab login") pat = str(creds.get("personal_access_token") or "").strip() if not pat: raise GitLabReadError( - "GitLab token not found in local credentials. " - "Run: potpie gitlab login" + "GitLab token not found in local credentials. Run: potpie gitlab login" ) return creds def _api_base(creds: dict[str, Any]) -> str: instance_url = str(creds.get("instance_url") or "").strip() - normalized = normalize_instance_url(instance_url) if instance_url else GITLAB_DEFAULT_INSTANCE + normalized = ( + normalize_instance_url(instance_url) + if instance_url + else GITLAB_DEFAULT_INSTANCE + ) return gitlab_api_base_url(normalized) @@ -85,9 +85,7 @@ def _get_json( "Run: potpie gitlab login --force" ) if response.status_code != 200: - raise GitLabReadError( - f"GitLab API returned HTTP {response.status_code}" - ) + raise GitLabReadError(f"GitLab API returned HTTP {response.status_code}") try: return response.json() except ValueError as exc: diff --git a/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py b/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py index 9c9d9dd5f..8207ec97b 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/integration_verify.py @@ -130,12 +130,12 @@ def _verify_gitlab( pat = str(credentials.get("personal_access_token") or "").strip() if not pat: return False, "not authenticated" - instance_url = str( - credentials.get("instance_url") or "https://gitlab.com" - ).strip() + instance_url = str(credentials.get("instance_url") or "https://gitlab.com").strip() ok, error_kind, user_data = verify_instance_access( - instance_url, pat, http=http, + instance_url, + pat, + http=http, ) if not ok: if error_kind == GitLabAuthErrorKind.INVALID_CREDENTIALS: diff --git a/potpie/context-engine/tests/_auth_fakes.py b/potpie/context-engine/tests/_auth_fakes.py index 35277e983..ca7aaf9d0 100644 --- a/potpie/context-engine/tests/_auth_fakes.py +++ b/potpie/context-engine/tests/_auth_fakes.py @@ -216,7 +216,8 @@ def save_confluence_workspace_prefs(self, *, space_key: str) -> None: # --- GitLab credentials + workspace prefs ---------------------------- def get_gitlab_credentials( - self, instance_host: str | None = None, + self, + instance_host: str | None = None, ) -> dict[str, Any]: gitlab = self.providers.get("gitlab", {}) if not gitlab: @@ -254,7 +255,8 @@ def save_gitlab_credentials( self.providers["gitlab"] = gitlab def clear_gitlab_credentials( - self, instance_host: str | None = None, + self, + instance_host: str | None = None, ) -> None: if instance_host: gitlab = dict(self.providers.get("gitlab", {})) diff --git a/potpie/context-engine/tests/unit/test_cli_gitlab.py b/potpie/context-engine/tests/unit/test_cli_gitlab.py index 4a235345d..350432c2b 100644 --- a/potpie/context-engine/tests/unit/test_cli_gitlab.py +++ b/potpie/context-engine/tests/unit/test_cli_gitlab.py @@ -2,24 +2,14 @@ from __future__ import annotations -import time -from typing import Any -from unittest.mock import patch -import httpx import pytest import typer from adapters.outbound.cli_auth import credentials_store as cs -from adapters.outbound.cli_auth.gitlab_client import ( - GitLabAuthErrorKind, - normalize_instance_url, - parse_user_profile, -) from adapters.outbound.cli_auth.gitlab_read_client import ( GitLabReadError, ) -from tests._auth_fakes import FakeAuthHttpClient pytestmark = pytest.mark.unit @@ -33,7 +23,9 @@ def _isolate_creds(tmp_path, monkeypatch): monkeypatch.setattr(cs, "config_dir", lambda: tmp_path) monkeypatch.setattr(cs, "credentials_path", lambda: tmp_path / "credentials.json") monkeypatch.setattr( - cs, "integration_secrets_path", lambda: tmp_path / "integration_secrets.json", + cs, + "integration_secrets_path", + lambda: tmp_path / "integration_secrets.json", ) @@ -290,14 +282,18 @@ def test_list_integration_providers_includes_gitlab() -> None: def test_load_gitlab_read_credentials_raises_when_not_connected() -> None: - from adapters.outbound.cli_auth.gitlab_read_client import load_gitlab_read_credentials + from adapters.outbound.cli_auth.gitlab_read_client import ( + load_gitlab_read_credentials, + ) with pytest.raises(GitLabReadError, match="not connected"): load_gitlab_read_credentials() def test_load_gitlab_read_credentials_raises_when_no_token() -> None: - from adapters.outbound.cli_auth.gitlab_read_client import load_gitlab_read_credentials + from adapters.outbound.cli_auth.gitlab_read_client import ( + load_gitlab_read_credentials, + ) cs.save_gitlab_credentials( { @@ -415,7 +411,9 @@ def test_run_gitlab_pat_auth_opens_browser_after_countdown( lambda url, **_kwargs: opened.append(url) or True, ) monkeypatch.setattr(gl_auth, "_wait_for_enter_or_auto_open", lambda: None) - monkeypatch.setattr(gl_auth, "_prompt_instance_url", lambda default="": "gitlab.com") + monkeypatch.setattr( + gl_auth, "_prompt_instance_url", lambda default="": "gitlab.com" + ) monkeypatch.setattr(gl_auth, "_prompt_pat", lambda: "glpat-test") monkeypatch.setattr( gl_auth, diff --git a/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py b/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py index 7a2514326..8160e6132 100644 --- a/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py +++ b/potpie/context-engine/tests/unit/test_cli_gitlab_commands.py @@ -36,7 +36,9 @@ def _isolate_creds(tmp_path, monkeypatch): monkeypatch.setattr(cs, "config_dir", lambda: tmp_path) monkeypatch.setattr(cs, "credentials_path", lambda: tmp_path / "credentials.json") monkeypatch.setattr( - cs, "integration_secrets_path", lambda: tmp_path / "integration_secrets.json", + cs, + "integration_secrets_path", + lambda: tmp_path / "integration_secrets.json", ) @@ -51,7 +53,9 @@ def _save_gitlab(*, host: str = "gitlab.com", token: str = "glpat-test") -> None ) -def _patch_read_http(monkeypatch: pytest.MonkeyPatch, responses: list[httpx.Response]) -> None: +def _patch_read_http( + monkeypatch: pytest.MonkeyPatch, responses: list[httpx.Response] +) -> None: from adapters.outbound.cli_auth import gitlab_read_client as gl_read_client fake = FakeAuthHttpClient(responses) @@ -69,15 +73,18 @@ def test_auth_failure_message_all_kinds() -> None: base = gl_auth._auth_failure_message(None, "https://gitlab.com") assert "Could not authenticate" in base invalid = gl_auth._auth_failure_message( - GitLabAuthErrorKind.INVALID_CREDENTIALS, "https://gitlab.com", + GitLabAuthErrorKind.INVALID_CREDENTIALS, + "https://gitlab.com", ) assert "Invalid personal access token" in invalid scopes = gl_auth._auth_failure_message( - GitLabAuthErrorKind.INSUFFICIENT_SCOPES, "https://gitlab.com", + GitLabAuthErrorKind.INSUFFICIENT_SCOPES, + "https://gitlab.com", ) assert "missing required scopes" in scopes unreachable = gl_auth._auth_failure_message( - GitLabAuthErrorKind.INSTANCE_UNREACHABLE, "https://git.corp.com", + GitLabAuthErrorKind.INSTANCE_UNREACHABLE, + "https://git.corp.com", ) assert "Cannot reach" in unreachable @@ -90,7 +97,9 @@ def test_detect_gitlab_from_git_remote(monkeypatch: pytest.MonkeyPatch) -> None: assert gl_auth._detect_gitlab_from_git_remote() == "https://gitlab.corp.com" -def test_detect_gitlab_from_git_remote_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: +def test_detect_gitlab_from_git_remote_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr( "adapters.inbound.cli.repo_location.current_git_remote", lambda _cwd: "github.com/acme/api", @@ -129,7 +138,9 @@ def test_run_gitlab_pat_auth_already_connected(tmp_path, capsys) -> None: assert "already connected" in out -def test_run_gitlab_pat_auth_non_tty_requires_flags(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_gitlab_pat_auth_non_tty_requires_flags( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr(gl_auth.sys.stdin, "isatty", lambda: False) with pytest.raises(typer.Exit) as exc: gl_auth.run_gitlab_pat_auth(force=True) @@ -273,7 +284,9 @@ def test_fetch_gitlab_projects_success(monkeypatch: pytest.MonkeyPatch) -> None: assert rows[0]["path_with_namespace"] == "acme/api" -def test_fetch_gitlab_projects_non_list_response(monkeypatch: pytest.MonkeyPatch) -> None: +def test_fetch_gitlab_projects_non_list_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: _save_gitlab() _patch_read_http(monkeypatch, [httpx.Response(200, json={"error": "nope"})]) assert fetch_gitlab_projects() == [] @@ -534,7 +547,9 @@ def test_gitlab_select_cli_json(monkeypatch: pytest.MonkeyPatch) -> None: assert '"merge_requests"' in result.stdout -def test_prompt_instance_url_defaults_to_gitlab_com(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prompt_instance_url_defaults_to_gitlab_com( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr(gl_auth.typer, "prompt", lambda *_a, **_k: "gitlab.com") assert gl_auth._prompt_instance_url() == "https://gitlab.com" diff --git a/potpie/context-engine/tests/unit/test_gitlab_client.py b/potpie/context-engine/tests/unit/test_gitlab_client.py index c4837b961..0ab52b9fe 100644 --- a/potpie/context-engine/tests/unit/test_gitlab_client.py +++ b/potpie/context-engine/tests/unit/test_gitlab_client.py @@ -148,11 +148,15 @@ def test_parse_user_profile_minimal() -> None: def test_verify_instance_access_success() -> None: - fake = FakeAuthHttpClient([ - httpx.Response(200, json={"id": 42, "username": "jane"}), - ]) + fake = FakeAuthHttpClient( + [ + httpx.Response(200, json={"id": 42, "username": "jane"}), + ] + ) ok, error_kind, data = verify_instance_access( - "https://gitlab.com", "glpat-test", http=fake, + "https://gitlab.com", + "glpat-test", + http=fake, ) assert ok is True assert error_kind is None @@ -162,7 +166,9 @@ def test_verify_instance_access_success() -> None: def test_verify_instance_access_unauthorized() -> None: fake = FakeAuthHttpClient([httpx.Response(401)]) ok, error_kind, data = verify_instance_access( - "https://gitlab.com", "bad-token", http=fake, + "https://gitlab.com", + "bad-token", + http=fake, ) assert ok is False assert error_kind == GitLabAuthErrorKind.INVALID_CREDENTIALS @@ -172,7 +178,9 @@ def test_verify_instance_access_unauthorized() -> None: def test_verify_instance_access_forbidden() -> None: fake = FakeAuthHttpClient([httpx.Response(403)]) ok, error_kind, _ = verify_instance_access( - "https://gitlab.com", "scoped-token", http=fake, + "https://gitlab.com", + "scoped-token", + http=fake, ) assert ok is False assert error_kind == GitLabAuthErrorKind.INSUFFICIENT_SCOPES @@ -186,7 +194,9 @@ def _raise(*a, **kw): fake = FakeAuthHttpClient(handler=_raise) ok, error_kind, _ = verify_instance_access( - "https://gitlab.corp.com", "tok", http=fake, + "https://gitlab.corp.com", + "tok", + http=fake, ) assert ok is False assert error_kind == GitLabAuthErrorKind.INSTANCE_UNREACHABLE @@ -254,10 +264,12 @@ def test_gitlab_account_from_entry_empty() -> None: def test_verify_integration_access_gitlab_ok() -> None: - fake = FakeAuthHttpClient([ - httpx.Response(200, json={"id": 42, "username": "jane", "name": "Jane"}), - httpx.Response(200, json=[]), - ]) + fake = FakeAuthHttpClient( + [ + httpx.Response(200, json={"id": 42, "username": "jane", "name": "Jane"}), + httpx.Response(200, json=[]), + ] + ) ok, msg = verify_integration_access( "gitlab", { From e32169d047c269ff95a47a379813202d8e76b693 Mon Sep 17 00:00:00 2001 From: jdyaberi-pp Date: Mon, 6 Jul 2026 22:02:56 +0530 Subject: [PATCH 4/4] Format GitLab credential protocol stubs for pre-commit. Co-authored-by: Cursor --- .../adapters/outbound/cli_auth/credentials.py | 9 ++++++--- .../context-engine/domain/ports/cli_auth/credentials.py | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/potpie/context-engine/adapters/outbound/cli_auth/credentials.py b/potpie/context-engine/adapters/outbound/cli_auth/credentials.py index ee61818a9..b4cf368b3 100644 --- a/potpie/context-engine/adapters/outbound/cli_auth/credentials.py +++ b/potpie/context-engine/adapters/outbound/cli_auth/credentials.py @@ -137,7 +137,8 @@ def save_confluence_workspace_prefs(self, *, space_key: str) -> None: # --- GitLab credentials + workspace prefs ---------------------------- def get_gitlab_credentials( - self, instance_host: str | None = None, + self, + instance_host: str | None = None, ) -> dict[str, Any]: return _store.get_gitlab_credentials(instance_host=instance_host) @@ -150,7 +151,8 @@ def save_gitlab_credentials( _store.save_gitlab_credentials(credentials, account=account) def clear_gitlab_credentials( - self, instance_host: str | None = None, + self, + instance_host: str | None = None, ) -> None: _store.clear_gitlab_credentials(instance_host=instance_host) @@ -164,7 +166,8 @@ def save_gitlab_workspace_prefs( default_project: str, ) -> None: _store.save_gitlab_workspace_prefs( - instance_host=instance_host, default_project=default_project, + instance_host=instance_host, + default_project=default_project, ) diff --git a/potpie/context-engine/domain/ports/cli_auth/credentials.py b/potpie/context-engine/domain/ports/cli_auth/credentials.py index d6755a571..cc4e1bd71 100644 --- a/potpie/context-engine/domain/ports/cli_auth/credentials.py +++ b/potpie/context-engine/domain/ports/cli_auth/credentials.py @@ -101,7 +101,8 @@ def save_confluence_workspace_prefs(self, *, space_key: str) -> None: ... # --- GitLab credentials + workspace prefs ---------------------------- def get_gitlab_credentials( - self, instance_host: str | None = None, + self, + instance_host: str | None = None, ) -> dict[str, Any]: ... def save_gitlab_credentials( @@ -112,7 +113,8 @@ def save_gitlab_credentials( ) -> None: ... def clear_gitlab_credentials( - self, instance_host: str | None = None, + self, + instance_host: str | None = None, ) -> None: ... def list_gitlab_instances(self) -> list[dict[str, Any]]: ...