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.
+ | Author | Org | Repo | PR | Description | Status |
|---|
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 `
+ | ${esc(r.author)} |
+ ${esc(r.org)} |
+ ${esc(r.repo)} |
+ ${href} |
+ ${esc(r.title)} |
+ ${esc(r.status)} |
+
`;
+ }).join("") || "| No open PRs for visible people. |
";
+ }
+
// 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"{col} | " 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"]