diff --git a/PROTOCOL.md b/PROTOCOL.md index f7d6a6d..014cc14 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -254,8 +254,9 @@ Otherwise JSON snapshot: Cookie session after GitHub OAuth. -- `GET /auth/me` — login, visible logins, teams. GitHub OAuth is sign-in only (`read:user`); the website never calls GitHub for dashboard data. -- `GET /api/state` — materialized rows the caller may see (sessions include `can_control` and `control_connected`). The signed-in website shows only this replica. Replica arrays are newest-first (`updated_at` descending, then `row_id`). `devices` is newest-first (`created_at` descending, then `id`). +- `GET /auth/me` — login, visible logins, teams. GitHub OAuth is sign-in and PR-search (`read:user repo`); the token is not in the cookie. +- `GET /api/prs` — open GitHub pull requests authored or assigned to visible logins. Search uses the signed-in user's OAuth token (scope `read:user repo`), stored in hub sqlite (`oauth_token`) and memory, not in the cookie. `source` is `github` or `none` (signed in, but no token — sign in again). Columns: author, org, repo, number, title, status (`open`|`draft`), url. `truncated` is true when 50 rows were returned. +- `GET /api/state` — materialized rows the caller may see (sessions include `can_control` and `control_connected`). The signed-in website leads with `/api/prs`, then replica tables from `/api/state`. Replica arrays are newest-first (`updated_at` descending, then `row_id`). `devices` is newest-first (`created_at` descending, then `id`). - `GET /api/sessions/{id}` — one visible session plus control flags. - `POST /api/sessions/{id}/control` — start / stop / input / resize (owner device only). - `GET /api/sessions/{id}/terminal` — JSON ring snapshot or SSE terminal stream. diff --git a/README.md b/README.md index f55b728..dcd5059 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Image builds on `develop` / `main` push `dfxswiss/agent-core:beta` / `:latest` a ## Run locally -Create a GitHub OAuth App whose callback is `http://127.0.0.1:8787/auth/github/callback`. The hub requests scope `read:user` so people can sign in. The website shows only the local hub replica — sessions, tasks, agents, pings, devices, usage snapshots. Then: +Create a GitHub OAuth App whose callback is `http://127.0.0.1:8787/auth/github/callback`. The hub requests scope `read:user repo` so people can sign in and the website can list open pull requests. After sign-in the website leads with open PRs from GitHub (`GET /api/prs`); usage, sessions, tasks, agents, pings, and devices come from the local hub replica. Then: ```bash python3 -m venv .venv diff --git a/src/agent_core/app.py b/src/agent_core/app.py index 7e87c51..f5ac561 100644 --- a/src/agent_core/app.py +++ b/src/agent_core/app.py @@ -59,6 +59,9 @@ def __init__(self, cfg: Config, github: GitHub, store: Store, teams: dict[str, l self.control_ready: dict[str, asyncio.Queue[dict[str, Any]]] = {} self.terminal_rings: dict[str, list[dict[str, Any]]] = {} self.terminal_queues: list[tuple[str, str, asyncio.Queue[dict[str, Any]]]] = [] + self.github_tokens: dict[str, str] = {} + for login, token in self.store.all_oauth_tokens(): + self.github_tokens[login] = token def visible(self, login: str) -> set[str]: return visible_logins(login, self.teams) @@ -185,6 +188,9 @@ def auth_callback(request: Request, code: str | None = None, state: str | None = raise HTTPException(status_code=401, detail=str(exc)) from exc request.session.pop("oauth_state", None) request.session["login"] = user.login + if user.token: + hub.github_tokens[user.login] = user.token + hub.store.put_oauth_token(user.login, user.token) dest = request.session.pop("after_login", "/") if not isinstance(dest, str) or not dest.startswith("/"): dest = "/" @@ -192,6 +198,10 @@ def auth_callback(request: Request, code: str | None = None, state: str | None = @app.post("/auth/logout") def auth_logout(request: Request) -> JSONResponse: + login = request.session.get("login") + if isinstance(login, str) and login: + hub.store.delete_oauth_token(login) + hub.github_tokens.pop(login, None) request.session.clear() return JSONResponse({"ok": True}) @@ -386,6 +396,31 @@ def sync_query(request: Request, body: dict[str, Any]) -> dict[str, Any]: match = _validate_match(body["match"]) return {"rows": _query_matching_rows(hub, device["github_login"], match)} + @app.get("/api/prs") + def api_prs(request: Request) -> dict[str, Any]: + login = hub.session_login(request) + allowed = sorted(hub.visible(login)) + token = hub.github_tokens.get(login, "") + if token == "": + token = hub.store.get_oauth_token(login) + if token: + hub.github_tokens[login] = token + if token == "": + return {"generated_at": utcnow(), "prs": [], "source": "none"} + failed = token + try: + prs = hub.github.search_open_prs(token, allowed) + except GitHubError as exc: + text = str(exc) + if "HTTP 401" in text: + hub.store.delete_oauth_token(login, failed) + if hub.github_tokens.get(login) == failed: + hub.github_tokens.pop(login, None) + return {"generated_at": utcnow(), "prs": [], "source": "none"} + raise HTTPException(status_code=502, detail=text) from exc + truncated = len(prs) >= 50 + return {"generated_at": utcnow(), "prs": prs, "source": "github", "truncated": truncated} + @app.get("/api/state") def api_state(request: Request) -> dict[str, Any]: login = hub.session_login(request) diff --git a/src/agent_core/db.py b/src/agent_core/db.py index 3d8d38a..8374861 100644 --- a/src/agent_core/db.py +++ b/src/agent_core/db.py @@ -59,6 +59,12 @@ FOREIGN KEY (device_id) REFERENCES device(id) ); +CREATE TABLE IF NOT EXISTS oauth_token ( + github_login TEXT PRIMARY KEY, + token TEXT NOT NULL, + updated_at TEXT NOT NULL +); + CREATE INDEX IF NOT EXISTS event_origin_idx ON ledger_event (origin_device_id, origin_seq); CREATE INDEX IF NOT EXISTS replica_login_idx ON row_replica (github_login, table_name); CREATE INDEX IF NOT EXISTS device_login_idx ON device (github_login); @@ -77,7 +83,6 @@ def __init__(self, path: str) -> None: self._conn.execute("PRAGMA foreign_keys = ON") self._conn.execute("PRAGMA journal_mode = WAL") self._conn.executescript(SCHEMA) - self._conn.execute("DROP TABLE IF EXISTS oauth_token") self._conn.commit() def close(self) -> None: @@ -140,6 +145,40 @@ def replace_device_subscriptions(self, device_id: str, match_jsons: list[str]) - self._conn.rollback() raise + def put_oauth_token(self, github_login: str, token: str) -> None: + self.execute( + "INSERT INTO oauth_token (github_login, token, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(github_login) DO UPDATE SET token = excluded.token, updated_at = excluded.updated_at", + (github_login, token, utcnow()), + ) + + def get_oauth_token(self, github_login: str) -> str: + row = self.query_one("SELECT token FROM oauth_token WHERE github_login = ?", (github_login,)) + if row is None: + return "" + token = row["token"] + return token if isinstance(token, str) else "" + + def delete_oauth_token(self, github_login: str, token: str | None = None) -> None: + if token is None: + self.execute("DELETE FROM oauth_token WHERE github_login = ?", (github_login,)) + return + self.execute( + "DELETE FROM oauth_token WHERE github_login = ? AND token = ?", + (github_login, token), + ) + + def all_oauth_tokens(self) -> list[tuple[str, str]]: + rows = self.query("SELECT github_login, token FROM oauth_token") + out: list[tuple[str, str]] = [] + for row in rows: + login = row["github_login"] + token = row["token"] + if isinstance(login, str) and isinstance(token, str) and login and token: + out.append((login, token)) + return out + + def row_dict(row: sqlite3.Row) -> dict[str, Any]: return {k: row[k] for k in row.keys()} diff --git a/src/agent_core/github.py b/src/agent_core/github.py index c240ea6..419ed3b 100644 --- a/src/agent_core/github.py +++ b/src/agent_core/github.py @@ -1,14 +1,17 @@ -"""GitHub OAuth for hub sign-in. Tests inject FakeGitHub.""" +"""GitHub OAuth and open-PR search. Tests inject FakeGitHub.""" from __future__ import annotations from dataclasses import dataclass -from urllib.parse import urlencode +from typing import Any +from urllib.parse import urlencode, urlparse import httpx from .config import Config +GITHUB_SEARCH_URL = "https://api.github.com/search/issues" + class GitHubError(ValueError): pass @@ -17,6 +20,7 @@ class GitHubError(ValueError): @dataclass(frozen=True) class GitHubUser: login: str + token: str = "" class GitHub: @@ -26,6 +30,38 @@ def authorize_url(self, state: str, redirect_uri: str) -> str: def login_for_code(self, code: str) -> GitHubUser: raise NotImplementedError + def search_open_prs(self, token: str, logins: list[str]) -> list[dict[str, Any]]: + raise NotImplementedError + + +def _pr_from_search_item(item: dict[str, Any]) -> dict[str, Any] | None: + html = item.get("html_url") + if not isinstance(html, str) or "/pull/" not in html: + return None + path = urlparse(html).path.strip("/").split("/") + # owner/repo/pull/N + if len(path) < 4 or path[2] != "pull": + return None + org, repo, _, num = path[0], path[1], path[2], path[3] + if not num.isdigit(): + return None + user = item.get("user") if isinstance(item.get("user"), dict) else {} + author = user.get("login") if isinstance(user, dict) else "" + if not isinstance(author, str) or author == "": + author = "" + title = item.get("title") if isinstance(item.get("title"), str) else "" + draft = bool(item.get("draft")) + status = "draft" if draft else (item.get("state") if isinstance(item.get("state"), str) else "open") + return { + "author": author.lower(), + "org": org, + "repo": repo, + "number": int(num), + "title": title, + "status": status, + "url": html, + } + class RealGitHub(GitHub): def __init__(self, cfg: Config) -> None: @@ -36,7 +72,7 @@ def authorize_url(self, state: str, redirect_uri: str) -> str: { "client_id": self._cfg.github_client_id, "redirect_uri": redirect_uri, - "scope": "read:user", + "scope": "read:user repo", "state": state, } ) @@ -71,16 +107,65 @@ def login_for_code(self, code: str) -> GitHubUser: login = user_resp.json().get("login") if not isinstance(login, str) or login.strip() == "": raise GitHubError("user lookup returned no login") - return GitHubUser(login=login.strip().lower()) + return GitHubUser(login=login.strip().lower(), token=access) + + def search_open_prs(self, token: str, logins: list[str]) -> list[dict[str, Any]]: + if token == "" or not logins: + return [] + unique = [] + seen: set[str] = set() + for login in logins: + low = login.strip().lower() + if low == "" or low in seen: + continue + seen.add(low) + unique.append(low) + clauses = [] + for login in unique: + clauses.append(f"author:{login}") + clauses.append(f"assignee:{login}") + q = "is:pr is:open (" + " OR ".join(clauses) + ")" + resp = httpx.get( + GITHUB_SEARCH_URL, + params={"q": q, "sort": "updated", "order": "desc", "per_page": 50}, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=20.0, + ) + if resp.status_code != 200: + raise GitHubError(f"PR search failed: HTTP {resp.status_code}") + body = resp.json() + items = body.get("items") if isinstance(body, dict) else None + if not isinstance(items, list): + raise GitHubError("PR search failed: HTTP 200") + out: list[dict[str, Any]] = [] + seen_keys: set[tuple[str, str, int]] = set() + for item in items: + if not isinstance(item, dict): + continue + row = _pr_from_search_item(item) + if row is None: + continue + key = (row["org"], row["repo"], row["number"]) + if key in seen_keys: + continue + seen_keys.add(key) + out.append(row) + return out class FakeGitHub(GitHub): """Maps authorization codes to logins. Used only by tests.""" - def __init__(self, codes: dict[str, str]) -> None: + def __init__(self, codes: dict[str, str], prs: list[dict[str, Any]] | None = None) -> None: if not codes: raise GitHubError("FakeGitHub requires at least one code") self._codes = {k: v.strip().lower() for k, v in codes.items()} + self._prs = list(prs or []) + self.search_status = 200 def authorize_url(self, state: str, redirect_uri: str) -> str: return f"https://github.test/login/oauth/authorize?state={state}&redirect_uri={redirect_uri}" @@ -88,4 +173,18 @@ def authorize_url(self, state: str, redirect_uri: str) -> str: def login_for_code(self, code: str) -> GitHubUser: if code not in self._codes: raise GitHubError(f"unknown authorization code: {code}") - return GitHubUser(login=self._codes[code]) + login = self._codes[code] + return GitHubUser(login=login, token="tok-" + login) + + def search_open_prs(self, token: str, logins: list[str]) -> list[dict[str, Any]]: + if self.search_status in (401, 403): + raise GitHubError(f"PR search failed: HTTP {self.search_status}") + if token == "" or not token.startswith("tok-"): + return [] + allowed = {m.strip().lower() for m in logins} + return [ + p + for p in self._prs + if str(p.get("author", "")).lower() in allowed + or str(p.get("assignee", "")).lower() in allowed + ] diff --git a/src/agent_core/static/index.html b/src/agent_core/static/index.html index 09c3489..0b50c07 100644 --- a/src/agent_core/static/index.html +++ b/src/agent_core/static/index.html @@ -153,12 +153,16 @@

Agent

+
Open PRs
Visible people
Open tasks
Working agents
Open pings
Grok usage
+

Pull requests

+

Open PRs authored or assigned to people you can see.

+
AuthorOrgRepoPRDescriptionStatus

Usage

@@ -249,6 +253,8 @@

Devices

let renderGen = 0; let stateInflight = null; let stateGen = 0; + let prsInflight = null; + let prsGen = 0; async function me() { const res = await fetch("/auth/me"); @@ -259,20 +265,25 @@

Devices

function abortHubFetches() { const s = stateInflight; stateInflight = null; + const p = prsInflight; prsInflight = null; if (s) s.ctl.abort(); + if (p) p.ctl.abort(); } function clearHubTables() { document.getElementById("k-tasks").textContent = "–"; document.getElementById("k-agents").textContent = "–"; document.getElementById("k-pings").textContent = "–"; + document.getElementById("k-prs").textContent = "–"; document.getElementById("k-usage").textContent = "–"; + document.getElementById("prs-sub").textContent = "Open PRs authored or assigned to people you can see."; document.getElementById("stamp").textContent = ""; fillSessions([]); fillUsage([]); fill("tasks", [], ["_github_login", "title", "state", "current_round", "workflow"]); fill("pings", [], ["from_login", "to_login", "kind", "task_id", "acked_at", "_ack"]); fill("devices", [], ["github_login", "name", "id", "created_at"]); + document.querySelector("#prs tbody").innerHTML = ""; closeSessionDetail(); } @@ -284,6 +295,29 @@

Devices

}).finally(() => clearTimeout(t)); } + function fillPrs(rows, source) { + const tb = document.querySelector("#prs tbody"); + const sub = document.getElementById("prs-sub"); + if (source === "none") { + sub.innerHTML = 'GitHub access is missing. Sign in with GitHub again to load PRs.'; + tb.innerHTML = ""; + return; + } + sub.textContent = "Open PRs authored or assigned to people you can see."; + const list = rows || []; + tb.innerHTML = list.map(r => { + const href = r.url ? `#${esc(r.number)}` : esc(r.number); + return ` + + + + + + + `; + }).join("") || ""; + } + // Tables render GET /api/state as-is (newest first). Do not reverse or append. function fill(id, rows, cols) { const tb = document.querySelector("#" + id + " tbody"); @@ -479,6 +513,7 @@

Devices

return; } kickState(); + kickPrs(); } function kickState() { @@ -521,6 +556,28 @@

Devices

return p; } + function kickPrs() { + if (prsInflight) return prsInflight.p; + const prsCtl = new AbortController(); + const gen = ++prsGen; + const p = jsonGet("/api/prs", prsCtl, 25000); + prsInflight = { ctl: prsCtl, p, gen }; + p.then(prData => { + if (!prsInflight || prsInflight.gen !== gen) return; + const openPrs = prData.prs || []; + document.getElementById("k-prs").textContent = openPrs.length; + fillPrs(openPrs, prData.source); + prsInflight = null; + }).catch(e => { + if (!prsInflight || prsInflight.gen !== gen) return; + document.getElementById("k-prs").textContent = "–"; + document.getElementById("prs-sub").textContent = e.name === "AbortError" ? "Timed out loading PRs." : String(e); + document.querySelector("#prs tbody").innerHTML = ""; + prsInflight = null; + }); + return p; + } + document.getElementById("logout").onclick = async () => { await fetch("/auth/logout", { method: "POST" }); location.href = "/"; diff --git a/tests/test_prs.py b/tests/test_prs.py index ad4b0a6..1f2bfb1 100644 --- a/tests/test_prs.py +++ b/tests/test_prs.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import sqlite3 from base64 import b64decode from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -9,133 +8,291 @@ from fastapi.testclient import TestClient from itsdangerous import TimestampSigner +import httpx +import pytest + from agent_core.app import create_app -from agent_core.config import Config from agent_core.db import Store -from agent_core.github import FakeGitHub, RealGitHub +from agent_core.github import FakeGitHub, GitHubError, RealGitHub, _pr_from_search_item from tests.conftest import sign_in -def _session_payload(cfg: Config, client: TestClient) -> dict: - raw = client.cookies["session"] - data = TimestampSigner(cfg.session_secret).unsign(raw.encode("utf-8"), max_age=14 * 24 * 60 * 60) - payload = json.loads(b64decode(data)) - if not isinstance(payload, dict): - raise AssertionError("session cookie is not an object") - return payload +def test_pr_from_search_item() -> None: + row = _pr_from_search_item( + { + "html_url": "https://github.com/DFXswiss/agent/pull/11", + "title": "Add allow", + "state": "open", + "draft": True, + "user": {"login": "TaprootFreakAI"}, + } + ) + assert row == { + "author": "taprootfreakai", + "org": "DFXswiss", + "repo": "agent", + "number": 11, + "title": "Add allow", + "status": "draft", + "url": "https://github.com/DFXswiss/agent/pull/11", + } + +def test_api_prs_requires_login(hub) -> None: + res = hub.get("/api/prs") + assert res.status_code == 401 -def test_api_prs_is_gone(hub) -> None: + +def test_api_prs_team_filter(hub, github: FakeGitHub) -> None: + github._prs = [ + { + "author": "alice", + "org": "acme", + "repo": "app", + "number": 7, + "title": "Fix login", + "status": "draft", + "url": "https://github.com/acme/app/pull/7", + }, + { + "author": "bob", + "org": "acme", + "repo": "app", + "number": 8, + "title": "Add sync", + "status": "open", + "url": "https://github.com/acme/app/pull/8", + }, + { + "author": "cara", + "org": "other", + "repo": "x", + "number": 1, + "title": "secret", + "status": "open", + "url": "https://github.com/other/x/pull/1", + }, + ] sign_in(hub, "code-alice") - assert hub.get("/api/prs").status_code == 404 + body = hub.get("/api/prs").json() + assert body["source"] == "github" + numbers = sorted(p["number"] for p in body["prs"]) + assert numbers == [7, 8] + authors = {p["author"] for p in body["prs"]} + assert authors == {"alice", "bob"} -def test_index_has_no_github_pr_ui() -> None: - html = ( - Path(__file__).resolve().parents[1] / "src" / "agent_core" / "static" / "index.html" - ).read_text(encoding="utf-8") - assert 'id="prs"' not in html - assert 'id="k-prs"' not in html - assert 'id="prs-sub"' not in html - assert "/api/prs" not in html - assert "fillPrs" not in html - assert "kickPrs" not in html - assert "Open PRs" not in html - assert "api.github.com" not in html - assert "jsonGet(\"/api/state\"" in html - assert "kickState();" in html - assert "await kickState" not in html - - -def test_oauth_scope_is_read_user_only(cfg) -> None: - url = RealGitHub(cfg).authorize_url("st", "http://127.0.0.1/auth/github/callback") - query = parse_qs(urlparse(url).query) - assert query.get("scope") == ["read:user"] +def test_api_prs_self_only(hub, github: FakeGitHub) -> None: + github._prs = [ + { + "author": "dave", + "org": "other", + "repo": "x", + "number": 1, + "title": "secret", + "status": "open", + "url": "https://github.com/other/x/pull/1", + }, + { + "author": "alice", + "org": "acme", + "repo": "app", + "number": 7, + "title": "Fix login", + "status": "draft", + "url": "https://github.com/acme/app/pull/7", + }, + ] + sign_in(hub, "code-dave") + body = hub.get("/api/prs").json() + assert [p["number"] for p in body["prs"]] == [1] -def test_dashboard_state_comes_from_replica(hub) -> None: +def test_api_prs_includes_assignee(hub, github: FakeGitHub) -> None: + github._prs = [ + { + "author": "outside", + "assignee": "alice", + "org": "acme", + "repo": "app", + "number": 9, + "title": "Help alice", + "status": "open", + "url": "https://github.com/acme/app/pull/9", + } + ] sign_in(hub, "code-alice") - res = hub.get("/api/state") - assert res.status_code == 200 - body = res.json() - assert "session" in body and "task" in body and "devices" in body - assert "prs" not in body + body = hub.get("/api/prs").json() + assert [p["number"] for p in body["prs"]] == [9] + + +def test_prs_survive_new_hub(cfg, github: FakeGitHub) -> None: + github._prs = [ + { + "author": "alice", + "org": "acme", + "repo": "app", + "number": 7, + "title": "Fix login", + "status": "draft", + "url": "https://github.com/acme/app/pull/7", + } + ] + store = Store(cfg.database) + first = TestClient(create_app(cfg, github=github, store=store)) + sign_in(first, "code-alice") + assert first.get("/api/prs").json()["source"] == "github" + store.close() + second = TestClient(create_app(cfg, github=github, store=Store(cfg.database))) + second.cookies.update(first.cookies) + body = second.get("/api/prs").json() + assert body["source"] == "github" + assert [p["number"] for p in body["prs"]] == [7] -def test_auth_me_has_no_token(cfg, hub) -> None: +def test_auth_me_has_no_token(hub, cfg) -> None: sign_in(hub, "code-alice") me = hub.get("/auth/me").json() assert set(me) == {"login", "visible", "teams"} assert "token" not in me and "github_token" not in me - assert set(_session_payload(cfg, hub)) == {"login"} + raw = hub.cookies["session"] + data = TimestampSigner(cfg.session_secret).unsign( + raw.encode("utf-8"), max_age=14 * 24 * 60 * 60 + ) + payload = json.loads(b64decode(data)) + assert payload == {"login": "alice"} + store = Store(cfg.database) + assert store.get_oauth_token("alice") == "tok-alice" + + +def test_logout_drops_token(hub, github: FakeGitHub, cfg) -> None: + github._prs = [ + { + "author": "alice", + "org": "acme", + "repo": "app", + "number": 7, + "title": "Fix login", + "status": "open", + "url": "https://github.com/acme/app/pull/7", + } + ] + sign_in(hub, "code-alice") + assert hub.get("/api/prs").json()["source"] == "github" + hub.post("/auth/logout") + assert hub.get("/api/prs").status_code == 401 + store = Store(cfg.database) + assert store.get_oauth_token("alice") == "" + + +def test_expired_github_token_asks_reauth(hub, github: FakeGitHub) -> None: + sign_in(hub, "code-alice") + github.search_status = 401 + body = hub.get("/api/prs").json() + assert body["source"] == "none" + assert body["prs"] == [] + github.search_status = 200 + assert hub.get("/api/prs").json()["source"] == "none" -def test_real_oauth_access_token_is_not_stored(cfg, monkeypatch) -> None: +def test_rate_limit_does_not_drop_token(hub, github: FakeGitHub, cfg) -> None: + sign_in(hub, "code-alice") + github.search_status = 403 + res = hub.get("/api/prs") + assert res.status_code == 502 + assert "HTTP 403" in res.json()["detail"] + store = Store(cfg.database) + assert store.get_oauth_token("alice") == "tok-alice" + + +def test_malformed_search_items_is_502(hub, cfg, monkeypatch: pytest.MonkeyPatch) -> None: + sign_in(hub, "code-alice") + hub.app.state.hub.github = RealGitHub(cfg) + class _Resp: - def __init__(self, body: dict) -> None: - self.status_code = 200 - self._body = body + status_code = 200 def json(self) -> dict: - return self._body + return {"items": None} - monkeypatch.setattr( - "agent_core.github.httpx.post", - lambda *a, **k: _Resp({"access_token": "gh-secret-token"}), - ) - monkeypatch.setattr( - "agent_core.github.httpx.get", - lambda *a, **k: _Resp({"login": "Alice"}), - ) - client = TestClient(create_app(cfg, github=RealGitHub(cfg), store=Store(cfg.database))) - sign_in(client, "any-code") - me = client.get("/auth/me").json() - assert set(me) == {"login", "visible", "teams"} - assert me["login"] == "alice" - session = _session_payload(cfg, client) - assert session == {"login": "alice"} - assert "gh-secret-token" not in json.dumps(session) - db = sqlite3.connect(cfg.database) - try: - tables = [ - r[0] - for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() - ] - assert "oauth_token" not in tables - finally: - db.close() - assert b"gh-secret-token" not in Path(cfg.database).read_bytes() - - -def test_logout_clears_session(hub) -> None: + monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp()) + res = hub.get("/api/prs") + assert res.status_code == 502 + assert "HTTP 200" in res.json()["detail"] + store = Store(cfg.database) + assert store.get_oauth_token("alice") == "tok-alice" + with pytest.raises(GitHubError, match="HTTP 200"): + RealGitHub(cfg).search_open_prs("tok-alice", ["alice"]) + + +def test_dashboard_state_comes_from_replica(hub) -> None: sign_in(hub, "code-alice") - assert hub.get("/auth/me").status_code == 200 - hub.post("/auth/logout") - assert hub.get("/auth/me").status_code == 401 + res = hub.get("/api/state") + assert res.status_code == 200 + body = res.json() + assert "session" in body and "task" in body and "devices" in body + assert "prs" not in body -def test_oauth_token_table_is_dropped(cfg, github: FakeGitHub) -> None: - conn = sqlite3.connect(cfg.database) - conn.execute( - "CREATE TABLE oauth_token (github_login TEXT PRIMARY KEY, token TEXT NOT NULL)" - ) - conn.execute( - "INSERT INTO oauth_token (github_login, token) VALUES (?, ?)", - ("alice", "legacy-secret-token"), - ) - conn.commit() - conn.close() - store = Store(cfg.database) - create_app(cfg, github=github, store=store) - row = store.query_one( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'oauth_token'" - ) - assert row is None - leftover = sqlite3.connect(cfg.database) - try: - n = leftover.execute( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'oauth_token'" - ).fetchone()[0] - assert n == 0 - finally: - leftover.close() +def test_index_lists_pr_columns() -> None: + html = ( + Path(__file__).resolve().parents[1] / "src" / "agent_core" / "static" / "index.html" + ).read_text(encoding="utf-8") + for col in ("Author", "Org", "Repo", "PR", "Description", "Status"): + assert f"" in html + assert 'id="prs"' in html + assert 'id="k-prs"' in html + assert "Open PRs" in html + assert "/api/prs" in html + assert "fillPrs" in html + assert "kickPrs" in html + assert html.index('id="prs"') < html.index('id="usage"') + assert html.index("

Pull requests

") < html.index("

Usage

") + assert html.index('id="prs"') < html.index('id="sessions"') + assert html.index('id="err"') < html.index('id="signed-in"') + render_fn = html.split("async function render()", 1)[1].split("function kickState()", 1)[0] + assert 'k-people").textContent' in render_fn + assert "kickState();" in render_fn + assert "kickPrs();" in render_fn + assert "await kickState" not in render_fn + assert "await kickPrs" not in render_fn + assert html.index('k-people").textContent') < html.index("kickState();") + assert html.index("kickState();") < html.index("kickPrs();") + after_me = render_fn.split("await me()", 1)[1] + assert "if (gen !== renderGen) return" in after_me.split("catch", 1)[0] + assert html.index("if (stateInflight)") < html.index('jsonGet("/api/state"') + assert html.index("if (prsInflight)") < html.index('jsonGet("/api/prs"') + assert "stateInflight.gen !== gen" in html + assert "prsInflight.gen !== gen" in html + assert "if (stateCtl) stateCtl.abort()" not in html + assert "if (prsCtl) prsCtl.abort()" not in html + signed_out = html.split("if (!user)", 1)[1].split("window.__login", 1)[0] + assert "abortHubFetches()" in signed_out + assert "err.hidden = true" in signed_out + switch_fn = html.split("window.__login !== user.login", 1)[1].split("window.__login = user.login", 1)[0] + assert "abortHubFetches()" in switch_fn + assert "clearHubTables()" in switch_fn + clear_fn = html.split("function clearHubTables()", 1)[1].split("function jsonGet", 1)[0] + assert 'id="prs-sub"' in html + assert "prs-sub" in clear_fn + assert "Open PRs authored or assigned to people you can see." in clear_fn + assert "k-prs" in clear_fn + assert "k-usage" in clear_fn + catch_fn = html.split("async function render()", 1)[1].split("function kickState()", 1)[0].split("} catch (e)", 1)[1] + assert "abortHubFetches()" in catch_fn + assert "err.hidden = false" in catch_fn + assert 'jsonGet("/api/state", stateCtl, 15000)' in html + assert 'jsonGet("/api/prs", prsCtl, 25000)' in html + assert "Timed out loading sessions." in html + assert "Timed out loading PRs." in html + assert "GitHub access is missing" in html + assert "source === \"none\"" in html or "source === 'none'" in html + assert "No open PRs for visible people." in html + assert 'id="usage"' in html + assert 'id="k-usage"' in html + + +def test_oauth_scope_is_read_user_repo(cfg) -> None: + url = RealGitHub(cfg).authorize_url("st", "http://127.0.0.1/auth/github/callback") + query = parse_qs(urlparse(url).query) + assert query.get("scope") == ["read:user repo"]
${esc(r.author)}${esc(r.org)}${esc(r.repo)}${href}${esc(r.title)}${esc(r.status)}
No open PRs for visible people.
{col}