From 700092cbbc428266099c5cfe9cfc165b771b3807 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 25 Sep 2026 16:24:13 -0400 Subject: [PATCH] Add omp profile selection and a GitHub account flag `--profile` isolates omp usage and cache, and labels each provider with that account's email from `omp usage`. `--github` scopes MY PRS and REVIEW REQUESTED to a named gh login without switching the active account. --- CHANGELOG.md | 19 +++- README.md | 40 +++++++- config.example.toml | 4 + devdash.py | 219 ++++++++++++++++++++++++++++++++++++------ tests/test_devdash.py | 183 ++++++++++++++++++++++++++++++++++- 5 files changed, 424 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3a2f0..527e613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,25 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] -### Fixed - -- CI badge uses the latest run of each check name, matching the PR merge box. A cancelled - workflow's failed `Verify / gate` no longer keeps a later green run red. - ### Added +- `--profile NAME` selects the matching isolated omp profile for usage and cached readings; + only that profile's authenticated providers appear. Provider headings include that + provider's account email in white, for example `Codex (user@example.com)`. +- `--github USER` (config: `github.account`) selects a `gh` login for MY PRS and REVIEW + REQUESTED without switching the active account. +- OpenRouter per-key spend, remaining credits, and key-limit display using the selected + omp profile's credential. +- Nous Portal subscription and remaining credits when the selected omp profile has a + `nous-portal` or `nous` credential. - Pace icons on weekly and monthly usage bars: `>` to `>>>` when you use a quota faster than time passes, `<` to `<<<` when slower, `|` on pace. Turn them off with `usage.pace = false`. +### Fixed + +- CI badge uses the latest run of each check name, matching the PR merge box. A cancelled + workflow's failed `Verify / gate` no longer keeps a later green run red. + ## [0.1.0] - 2026-09-23 ### Added diff --git a/README.md b/README.md index 4b6fd78..823953e 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,13 @@ named `devdash`. and merge-queue position. - **Review requests.** PRs that wait for you, newest first, with the author and age, and a marker when someone requests your review again. -- **OMP AI usage (optional).** Rate-limit bars and reset countdowns for every provider that - the [OMP](https://omp.sh) coding agent tracks. Hidden when `omp` is not installed. Weekly and - monthly bars show a pace icon (`>>` too fast, `<<` too slow, `|` on pace). +- **OMP AI usage (optional).** Rate-limit bars and reset countdowns for providers with + usage reports in the selected [OMP](https://omp.sh) profile. OpenRouter spend and remaining + credits are read from its [key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-api-key) + and [credits](https://openrouter.ai/docs/api/api-reference/credits/get-credits) endpoints. + Nous Portal credits are read from the Portal account API when omp has a `nous-portal` or + `nous` credential. Hidden when `omp` is not installed. Weekly and monthly bars show a pace + icon (`>>` too fast, `<<` too slow, `|` on pace). - **Hide what you do not want to see.** Exclude single repositories or whole organizations. PR numbers are clickable in terminals that support hyperlinks. @@ -74,10 +78,26 @@ devdash --once # print one frame and exit devdash --exclude acme/monorepo # hide one repository devdash --exclude acme # hide every repository of a user or organization devdash --no-review --no-usage # show only your own PRs +devdash --profile personal --once # show usage from the personal omp profile +devdash --github alice # MY PRS / REVIEW REQUESTED for this gh login devdash --help # all flags ``` `--once` is also the behavior when standard input is not a terminal, so `devdash | less -R` works. +`--profile NAME` uses the same isolated auth, settings, and usage cache as `omp --profile NAME`. +Without the flag, devdash follows omp's default profile (or `$OMP_PROFILE`). Usage from one +profile is never reused as the last-good reading for another. Providers with an authenticated +account but no available usage data appear under `no data`. With `--profile`, each provider +heading includes that provider's account email in white, for example `Codex (user@example.com)`. + +`--github USER` is the GitHub username from `gh auth status` (not an email). It scopes MY PRS +and REVIEW REQUESTED to that login without running `gh auth switch`. It does not label usage. + +OpenRouter shows **per-key** spending, not your overall account balance. An API key limit +appears as a spend bar; an unlimited key shows dollars spent. If its key is not available +through `omp token openrouter`, or OpenRouter rejects it, the provider stays hidden. +Nous Portal has no published read-only usage/balance API; view its usage at +[Nous subscription management](https://portal.nousresearch.com/manage-subscription). ### Status row legend @@ -141,8 +161,8 @@ key or a value of the wrong type stops devdash with an error, so a typo cannot f ## Troubleshooting -**`gh is not signed in`.** Run `gh auth login`. devdash uses the account that `gh` uses; with -more than one account, `gh auth switch` selects it. +**`gh is not signed in`.** Run `gh auth login`. With more than one account, pass `--github USER` +(the username from `gh auth status`) or set `github.account` in the config file. **A PR is missing.** devdash shows the first 50 open PRs you wrote and the first 50 that request your review. Excluded repositories do not count toward the 50. @@ -156,6 +176,16 @@ and omp then drops the provider from its report. devdash keeps the last good rea and saves it in `$XDG_CACHE_HOME/devdash/`. To make fewer requests, raise `refetch_after` or use `--cached`. +**OpenRouter is missing.** `omp usage` does not report every configured model provider. +devdash queries OpenRouter's per-key usage and remaining-credits APIs with the key returned by +`omp --profile NAME token openrouter`; the key stays in memory and is never saved in +the dashboard cache. Check that the key belongs to the selected profile and remains valid. + +**Nous Portal is missing.** Portal credits are not part of `omp usage`. devdash calls +`https://portal.nousresearch.com/api/oauth/account` with `omp --profile NAME token nous-portal` +(or `nous`). A browser or Hermes login is not visible to omp; store the Portal credential in +the selected omp profile first. Inference keys that cannot read the account API are omitted. + ## Related projects - [gh-dash](https://github.com/dlvhdr/gh-dash) is a full-screen, interactive dashboard for PRs diff --git a/config.example.toml b/config.example.toml index 1041ef7..41f749b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -14,6 +14,10 @@ exclude = [] # Show the REVIEW REQUESTED section. review_requested = true +# GitHub username from `gh auth status` (`--github`). Empty uses gh's active account. +# account = "alice" +account = "" + [usage] # The USAGE section reads `omp usage`. "auto" shows it only when `omp` is on PATH. enabled = "auto" # true | false | "auto" diff --git a/devdash.py b/devdash.py index 77e0a41..5aee6f7 100755 --- a/devdash.py +++ b/devdash.py @@ -9,7 +9,7 @@ - `gh api graphql` your open PRs, grouped into native gh-stack stacks (or base->head chains for older ones), and PRs where your review is requested - - `omp usage --json --redact` every AI provider's limits and reset times + - `omp usage --json` every AI provider's limits and reset times (optional; shown only when `omp` is installed) Settings come from a TOML file (see --config); command-line flags win. @@ -18,6 +18,7 @@ import argparse import calendar +import hashlib import json import os import re @@ -31,6 +32,7 @@ import tty from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime +from urllib.request import Request, urlopen from rich.console import Console, Group from rich.live import Live @@ -69,14 +71,29 @@ def dur(sec): return f"{m}m" if m else f"{sec}s" -def run(cmd, timeout=45): - p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) +def run(cmd, timeout=45, env=None): + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env) if p.returncode: tail = (p.stderr or p.stdout).strip().splitlines() raise RuntimeError(tail[-1] if tail else f"{cmd[0]} failed") return p.stdout +def github_env(account): + """Use a named gh login for this process only; never switch the active account.""" + if not account: + return None + p = subprocess.run(["gh", "auth", "token", "-u", account], + capture_output=True, text=True, timeout=10) + token = p.stdout.strip() if p.returncode == 0 else "" + if not token: + tail = (p.stderr or "").strip().splitlines() + raise RuntimeError(tail[-1] if tail else f"gh is not signed in as {account}") + env = os.environ.copy() + env["GH_TOKEN"] = env["GITHUB_TOKEN"] = token + return env + + def line(*parts): """One screen row; Rich truncates it with an ellipsis at the pane width.""" t = Text(no_wrap=True, overflow="ellipsis") @@ -93,7 +110,7 @@ def line(*parts): # ── config ──────────────────────────────────────────────────────────────────── DEFAULTS = { "interval": 60, - "github": {"exclude": [], "review_requested": True}, + "github": {"exclude": [], "review_requested": True, "account": ""}, "usage": {"enabled": "auto", "refetch_after": 180, "pace": True, "order": [], "names": {}, "colors": {}}, } REPO_RULE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$") @@ -153,17 +170,134 @@ def is_excluded(repo, rules): # ── usage ───────────────────────────────────────────────────────────────────── -PROVIDER_ORDER = ["anthropic", "openai-codex", "google-antigravity", "cursor"] +PROVIDER_ORDER = ["anthropic", "openai-codex", "google-antigravity", "cursor", + "openrouter", "nous-portal"] PROVIDER_NAME = {"anthropic": "Anthropic", "openai-codex": "Codex", - "google-antigravity": "Antigravity", "cursor": "Cursor"} + "google-antigravity": "Antigravity", "cursor": "Cursor", + "openrouter": "OpenRouter", "nous-portal": "Nous Portal"} WINDOW_SHORT = {"5h": "5h", "7d": "7d", "weekly": "wk", "monthly": "mo", "extra": "extra"} +EXTRA_USAGE = ("openrouter", "nous-portal") -def fetch_usage(invalidate): +def fetch_usage(invalidate, profile=None): + prefix = ["--profile", profile] if profile is not None else [] if invalidate: - subprocess.run(["omp", "usage", "invalidate"], capture_output=True, timeout=30) - return json.loads(run(["omp", "usage", "--json", "--redact"])) + subprocess.run(["omp", *prefix, "usage", "invalidate"], capture_output=True, timeout=30) + data = json.loads(run(["omp", *prefix, "usage", "--json"])) + have = {r["provider"] for r in data.get("reports", [])} + for extra in (fetch_openrouter, fetch_nous_portal): + report = extra(profile) + if report and report["provider"] not in have: + data.setdefault("reports", []).append(report) + have.add(report["provider"]) + return data + + +def omp_token(profile, provider): + """Return a provider credential from omp without logging or storing it.""" + prefix = ["--profile", profile] if profile is not None else [] + token = subprocess.run(["omp", *prefix, "token", provider], + capture_output=True, text=True, timeout=10) + value = token.stdout.strip() if token.returncode == 0 else "" + return value or None + + +def bearer_json(url, token): + request = Request(url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}) + with urlopen(request, timeout=10) as response: + return json.load(response) + + +def num(value): + return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None + + +def epoch_ms(value): + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip()[:-1] + "+00:00" if value.strip().endswith("Z") else value.strip() + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return int(dt.timestamp() * 1000) + +def extra_report(provider, limits): + if not limits: + return None + return {"provider": provider, "fetchedAt": int(time.time() * 1000), "limits": limits} + + +def usd_amount(used, cap=None): + amount = {"used": used, "unit": "usd"} + if cap is not None: + amount["limit"] = cap + amount["usedFraction"] = used / cap if cap > 0 else 1.0 + return amount + + +def fetch_openrouter(profile): + """Use omp's selected profile credential without persisting or logging the key.""" + try: + token = omp_token(profile, "openrouter") + if not token: + return None + key = bearer_json("https://openrouter.ai/api/v1/key", token)["data"] + try: + credits = bearer_json("https://openrouter.ai/api/v1/credits", token).get("data") or {} + except (OSError, ValueError, KeyError, TypeError, TimeoutError): + credits = {} + period = key.get("limit_reset") + used = key.get({"daily": "usage_daily", "weekly": "usage_weekly", + "monthly": "usage_monthly"}.get(period, "usage")) + cap = num(key.get("limit")) + limits = [] + if used is not None: + limits.append({"label": "key spend", "amount": usd_amount(used, cap)}) + total_credits, total_usage = num(credits.get("total_credits")), num(credits.get("total_usage")) + if total_credits is not None and total_usage is not None: + limits.append({"label": "credits left", + "amount": usd_amount(max(0.0, total_credits - total_usage))}) + return extra_report("openrouter", limits) + except (OSError, ValueError, KeyError, TypeError, TimeoutError): + return None + + +def fetch_nous_portal(profile): + """Read Portal credits from omp's nous-portal/nous credential. Never reads Hermes auth files.""" + try: + token = omp_token(profile, "nous-portal") or omp_token(profile, "nous") + if not token: + return None + payload = bearer_json("https://portal.nousresearch.com/api/oauth/account", token) + if not isinstance(payload, dict) or payload.get("error"): + return None + sub = payload.get("subscription") if isinstance(payload.get("subscription"), dict) else {} + access = payload.get("paid_service_access") if isinstance(payload.get("paid_service_access"), dict) else {} + monthly, remaining = num(sub.get("monthly_credits")), num(sub.get("credits_remaining")) + total = num(access.get("total_usable_credits")) + purchased = num(access.get("purchased_credits_remaining")) + limits = [] + if monthly is not None and monthly > 0 and remaining is not None: + used = max(0.0, monthly - remaining) + lim = {"label": "subscription", "amount": usd_amount(used, monthly)} + reset = epoch_ms(sub.get("current_period_end")) + if reset: + lim["window"] = {"id": "monthly", "resetsAt": reset} + limits.append(lim) + left = total if total is not None else remaining + if left is not None and not limits: + limits.append({"label": "credits left", "amount": usd_amount(left)}) + elif total is not None and remaining is not None and total != remaining: + limits.append({"label": "credits left", "amount": usd_amount(total)}) + if purchased is not None and purchased > 0: + limits.append({"label": "top-up", "amount": usd_amount(purchased)}) + return extra_report("nous-portal", limits) + except (OSError, ValueError, KeyError, TypeError, TimeoutError): + return None def carry_over(old, new): @@ -181,21 +315,32 @@ def carry_over(old, new): return new -def load_last_good(): +def last_good_path(profile=None): + """Return the cache path for a selected omp profile, or the legacy default.""" + if profile is None: + profile = os.environ.get("OMP_PROFILE") + if profile is None: + return LAST_GOOD + suffix = hashlib.sha256(profile.encode()).hexdigest() + return LAST_GOOD.removesuffix(".json") + f"-{suffix}.json" + + +def load_last_good(profile=None): """The last good read survives restarts, so a launch during a 429 still shows Anthropic.""" try: - with open(LAST_GOOD) as f: + with open(last_good_path(profile)) as f: return json.load(f) except (OSError, ValueError): return None -def save_last_good(data): - os.makedirs(os.path.dirname(LAST_GOOD), exist_ok=True) - tmp = LAST_GOOD + ".tmp" +def save_last_good(data, profile=None): + path = last_good_path(profile) + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" with open(tmp, "w") as f: json.dump({"reports": data.get("reports", [])}, f) - os.replace(tmp, LAST_GOOD) + os.replace(tmp, path) def oldest_read(data): @@ -326,7 +471,8 @@ def usage_rows(report): for lim in report.get("limits", []): key = (lim["label"], (lim.get("window") or {}).get("resetsAt")) amt = lim.get("amount") or {} - if key in seen or (amt.get("usedFraction") is None and not amt.get("used")): + if key in seen or (amt.get("usedFraction") is None and not amt.get("used") + and report["provider"] not in EXTRA_USAGE): continue seen.add(key) rows.append(lim) @@ -360,7 +506,9 @@ def render_usage(st, width, now): brand = BRAND.get(p, "bright_white") if idx[p] == 1 and r is not rows[0][0]: out.append(Text()) - out.append(line(("● ", brand), (name, f"bold {brand}"), note)) + email = (r.get("metadata") or {}).get("email") if st.omp_profile else None + who = (f" ({email})", "bright_white") if email else "" + out.append(line(("● ", brand), (name, f"bold {brand}"), who, note)) for lim in limits: amt = lim.get("amount") or {} frac = amt.get("usedFraction") @@ -372,7 +520,9 @@ def render_usage(st, width, now): rst = (" " * 6, LABEL) label = short_label(lim).ljust(lab_w) if frac is None: # an uncapped counter - value = Text(f"{int(amt['used'])} {amt.get('unit', '')}".center(bar_w), style=f"{LABEL} on {TRACK}") + value = Text((f"${amt['used']:.2f}" if amt.get("unit") == "usd" + else f"{int(amt['used'])} {amt.get('unit', '')}").center(bar_w), + style=f"{LABEL} on {TRACK}") else: if amt.get("unit") == "usd" and amt.get("limit"): val = f"${amt['used']:.0f}/${amt['limit']:.0f}" @@ -388,8 +538,6 @@ def render_usage(st, width, now): missing = sorted({PROVIDER_NAME.get(a["provider"], a["provider"]) for a in data.get("accountsWithoutUsage", [])}) if missing: out.append(line((f"no data: {', '.join(missing)}", WARN))) - if data.get("disabledCredentials"): - out.append(line((f"{len(data['disabledCredentials'])} disabled credential(s)", META))) return out @@ -418,7 +566,7 @@ def render_usage(st, width, now): HAS_STACK = True -def fetch_prs(excluded, review_requested): +def fetch_prs(excluded, review_requested, account=None): global HAS_STACK # Search qualifiers keep excluded repos from eating the 50-result pages; # is_excluded below is the backstop for anything the search still returns. @@ -433,7 +581,8 @@ def fetch_prs(excluded, review_requested): while True: query = PR_FIELDS.replace("{stack}", STACK_FIELD if HAS_STACK else "") + body try: - data = json.loads(run(["gh", "api", "graphql", "-f", f"query={query}"])) + data = json.loads(run(["gh", "api", "graphql", "-f", f"query={query}"], + env=github_env(account))) break except RuntimeError as e: if not (HAS_STACK and "Field 'stack' doesn't exist" in str(e)): @@ -661,6 +810,9 @@ class State: usage = None usage_at = 0 usage_err = None + omp_profile = None + gh_account = None + cache_profile = None usage_inval_at = 0 mine, review = [], [] prs_at = 0 @@ -707,14 +859,14 @@ def refresh(st, pool, usage_every, force): age = oldest_read(st.usage) invalidate = (usage_every is not None and since >= 60 and (force or age is None or age >= usage_every)) - fu = pool.submit(fetch_usage, invalidate) - fp = pool.submit(fetch_prs, st.excluded, st.show_review) + fu = pool.submit(fetch_usage, invalidate, st.omp_profile) + fp = pool.submit(fetch_prs, st.excluded, st.show_review, st.gh_account) if fu: try: st.usage, st.usage_at, st.usage_err = carry_over(st.usage, fu.result()), time.time(), None if invalidate: st.usage_inval_at = now - save_last_good(st.usage) + save_last_good(st.usage, st.cache_profile) except Exception as e: # keep the last good data on screen st.usage_err = str(e)[:80] try: @@ -729,6 +881,11 @@ def parse_args(): formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("-c", "--config", metavar="PATH", help=f"TOML settings file (default $DEVDASH_CONFIG, else {CONFIG_PATH})") + ap.add_argument("--profile", metavar="NAME", + help="use this omp profile for usage and its saved cache") + ap.add_argument("--github", metavar="USER", + help="GitHub username for MY PRS and REVIEW REQUESTED " + "(config: github.account; from `gh auth status`)") ap.add_argument("-n", "--interval", type=int, help="seconds between refreshes (config: interval, 60)") ap.add_argument("--exclude", action="append", default=[], metavar="OWNER[/REPO]", help="hide a repository, or every repository of an owner; repeatable, " @@ -758,6 +915,7 @@ def main(): st = State() st.interval = args.interval or cfg["interval"] st.excluded = check_rules(list(gh["exclude"]) + args.exclude) + st.gh_account = args.github or gh["account"] or None st.show_review = gh["review_requested"] and not args.no_review st.show_usage = not args.no_usage and (usage["enabled"] is True or (usage["enabled"] == "auto" and shutil.which("omp") is not None)) @@ -773,11 +931,16 @@ def main(): # refetch; the saved last good reads fill any provider omp dropped. st.usage_inval_at = time.time() if st.show_usage: - st.usage = load_last_good() + st.omp_profile = args.profile + st.cache_profile = (args.profile if args.profile is not None + else os.environ.get("OMP_PROFILE")) + st.usage = load_last_good(st.cache_profile) try: - st.me = run(["gh", "api", "user", "--jq", ".login"]).strip() + st.me = run(["gh", "api", "user", "--jq", ".login"], env=github_env(st.gh_account)).strip() except RuntimeError as e: - raise SystemExit(f"devdash: gh is not signed in ({e}); run `gh auth login`") from None + hint = "; pass --github USER from `gh auth status`" if st.gh_account else "; run `gh auth login`" + who = f" as {st.gh_account}" if st.gh_account else "" + raise SystemExit(f"devdash: gh is not signed in{who} ({e}){hint}") from None pool = ThreadPoolExecutor(2) if args.once or not sys.stdin.isatty(): diff --git a/tests/test_devdash.py b/tests/test_devdash.py index 04f90af..717952c 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -33,7 +33,7 @@ def load(tmp_path, text): def test_config_merges_over_defaults(tmp_path): cfg = load(tmp_path, '[github]\nexclude = ["acme"]\n') - assert cfg["github"] == {"exclude": ["acme"], "review_requested": True} + assert cfg["github"] == {"exclude": ["acme"], "review_requested": True, "account": ""} assert cfg["interval"] == 60 @@ -55,6 +55,170 @@ def test_missing_config_is_an_error_only_when_named(tmp_path): devdash.load_config(str(tmp_path / "none.toml"), explicit=True) +def test_usage_profile_selects_omp_auth_and_isolates_last_good(tmp_path, monkeypatch): + monkeypatch.setattr(devdash, "LAST_GOOD", str(tmp_path / "last-usage.json")) + monkeypatch.delenv("OMP_PROFILE", raising=False) + devdash.save_last_good({"reports": [{"provider": "anthropic"}]}) + devdash.save_last_good({"reports": [{"provider": "cursor"}]}, "personal") + assert [r["provider"] for r in devdash.load_last_good()["reports"]] == ["anthropic"] + assert [r["provider"] for r in devdash.load_last_good("personal")["reports"]] == ["cursor"] + + commands = [] + monkeypatch.setattr(devdash.subprocess, "run", lambda cmd, **kw: commands.append(cmd)) + monkeypatch.setattr(devdash, "fetch_openrouter", lambda profile: None) + monkeypatch.setattr(devdash, "fetch_nous_portal", lambda profile: None) + + def fake_run(cmd, timeout=45, env=None): + commands.append(cmd) + return json.dumps({"reports": [{"provider": "cursor"}], "accountsWithoutUsage": []}) + + monkeypatch.setattr(devdash, "run", fake_run) + data = devdash.carry_over(devdash.load_last_good("personal"), devdash.fetch_usage(True, "personal")) + assert [r["provider"] for r in data["reports"]] == ["cursor"] + assert commands == [ + ["omp", "--profile", "personal", "usage", "invalidate"], + ["omp", "--profile", "personal", "usage", "--json"], + ] + + +def test_openrouter_reads_selected_profile_key_usage_without_storing_credential(monkeypatch): + from io import BytesIO + from subprocess import CompletedProcess + + commands = [] + + def fake_token(cmd, **kwargs): + commands.append(cmd) + return CompletedProcess(cmd, 0, stdout="private-key\n") + + def fake_urlopen(request, timeout): + assert request.get_header("Authorization") == "Bearer private-key" + if request.full_url == "https://openrouter.ai/api/v1/key": + return BytesIO(b'{"data":{"usage":99,"usage_monthly":4,"limit":10,"limit_reset":"monthly"}}') + if request.full_url == "https://openrouter.ai/api/v1/credits": + return BytesIO(b'{"data":{"total_credits":12,"total_usage":2}}') + raise AssertionError(request.full_url) + + monkeypatch.setattr(devdash.subprocess, "run", fake_token) + monkeypatch.setattr(devdash, "urlopen", fake_urlopen) + report = devdash.fetch_openrouter("personal") + assert commands == [["omp", "--profile", "personal", "token", "openrouter"]] + assert report["limits"][0]["amount"] == {"used": 4.0, "limit": 10.0, "usedFraction": 0.4, "unit": "usd"} + assert report["limits"][1]["amount"] == {"used": 10.0, "unit": "usd"} + assert "private-key" not in json.dumps(report) + + +def test_openrouter_uncapped_zero_usage_is_visible_and_failed_auth_is_hidden(monkeypatch): + from io import BytesIO + from subprocess import CompletedProcess + + monkeypatch.setattr(devdash.subprocess, "run", + lambda cmd, **kw: CompletedProcess(cmd, 0, stdout="private-key")) + monkeypatch.setattr(devdash, "urlopen", + lambda request, timeout: BytesIO(b'{"data":{"usage":0,"limit":null}}')) + report = devdash.fetch_openrouter(None) + st = devdash.State() + st.usage = {"reports": [report]} + assert "$0.00" in "\n".join(row.plain for row in devdash.render_usage( + st, 50, report["fetchedAt"] / 1000)) + monkeypatch.setattr(devdash.subprocess, "run", + lambda cmd, **kw: CompletedProcess(cmd, 1, stdout="")) + assert devdash.fetch_openrouter("personal") is None + + +def test_nous_portal_reads_subscription_credits_from_omp_token(monkeypatch): + from io import BytesIO + from subprocess import CompletedProcess + + commands = [] + + def fake_token(cmd, **kwargs): + commands.append(cmd) + provider = cmd[-1] + if provider == "nous-portal": + return CompletedProcess(cmd, 0, stdout="portal-token\n") + return CompletedProcess(cmd, 1, stdout="") + + def fake_urlopen(request, timeout): + assert request.full_url == "https://portal.nousresearch.com/api/oauth/account" + assert request.get_header("Authorization") == "Bearer portal-token" + return BytesIO(b'''{"subscription":{"monthly_credits":100,"credits_remaining":40, + "current_period_end":"2026-10-01T00:00:00Z"}, + "paid_service_access":{"total_usable_credits":55,"purchased_credits_remaining":15}}''') + + monkeypatch.setattr(devdash.subprocess, "run", fake_token) + monkeypatch.setattr(devdash, "urlopen", fake_urlopen) + report = devdash.fetch_nous_portal("personal") + assert commands == [["omp", "--profile", "personal", "token", "nous-portal"]] + labels = [lim["label"] for lim in report["limits"]] + assert labels == ["subscription", "credits left", "top-up"] + assert report["limits"][0]["amount"]["usedFraction"] == 0.6 + assert report["limits"][0]["window"]["id"] == "monthly" + assert "portal-token" not in json.dumps(report) + st = devdash.State() + st.usage = {"reports": [report]} + text = "\n".join(row.plain for row in devdash.render_usage(st, 60, report["fetchedAt"] / 1000)) + assert "Nous Portal" in text + monkeypatch.setattr(devdash.subprocess, "run", + lambda cmd, **kw: CompletedProcess(cmd, 1, stdout="")) + assert devdash.fetch_nous_portal("personal") is None + + +def test_omp_profile_environment_selects_cache_without_explicit_flag(tmp_path, monkeypatch): + monkeypatch.setattr(devdash, "LAST_GOOD", str(tmp_path / "last-usage.json")) + monkeypatch.delenv("OMP_PROFILE", raising=False) + devdash.save_last_good({"reports": [{"provider": "anthropic"}]}) + monkeypatch.setenv("OMP_PROFILE", "personal") + devdash.save_last_good({"reports": [{"provider": "cursor"}]}) + assert [r["provider"] for r in devdash.load_last_good()["reports"]] == ["cursor"] + monkeypatch.delenv("OMP_PROFILE") + assert [r["provider"] for r in devdash.load_last_good()["reports"]] == ["anthropic"] + + +def test_github_env_selects_named_login_without_switching_active_account(monkeypatch): + from subprocess import CompletedProcess + commands = [] + + def fake_run(cmd, **kwargs): + commands.append(cmd) + assert "token" not in (kwargs.get("env") or {}) + return CompletedProcess(cmd, 0, stdout="gh-token\n") + + monkeypatch.setattr(devdash.subprocess, "run", fake_run) + env = devdash.github_env("alice") + assert commands == [["gh", "auth", "token", "-u", "alice"]] + assert env["GH_TOKEN"] == env["GITHUB_TOKEN"] == "gh-token" + assert devdash.github_env("") is None + assert devdash.github_env(None) is None + + +def test_fetch_prs_pass_named_account_to_gh(monkeypatch): + envs = [] + + def fake_run(cmd, timeout=45, env=None): + envs.append(env) + return json.dumps({"data": {"mine": {"nodes": []}, "review": {"nodes": []}}}) + + monkeypatch.setattr(devdash, "github_env", lambda account: {"GH_TOKEN": f"for-{account}"}) + monkeypatch.setattr(devdash, "run", fake_run) + devdash.fetch_prs([], review_requested=True, account="alice") + assert envs == [{"GH_TOKEN": "for-alice"}] + + +def test_profile_shows_provider_email_in_white_next_to_name(): + st = devdash.State() + st.omp_profile = "personal" + st.me = "alice" + st.usage = {"reports": [{"provider": "openai-codex", "limits": [], + "metadata": {"email": "user@example.com"}}]} + row = next(r.plain for r in devdash.render_usage(st, 50, 0) if "Codex" in r.plain) + assert "Codex (user@example.com)" in row + assert "alice" not in row + st.omp_profile = None + row = next(r.plain for r in devdash.render_usage(st, 50, 0) if "Codex" in r.plain) + assert "user@example.com" not in row + + @pytest.mark.parametrize("rule", ["a b", "acme/web/x", 'acme" is:closed', ""]) def test_exclude_rules_cannot_inject_search_qualifiers(rule): with pytest.raises(SystemExit): @@ -90,7 +254,7 @@ def test_fetch_retries_without_stack_field_when_schema_lacks_it(monkeypatch): monkeypatch.setattr(devdash, "HAS_STACK", True) queries = [] - def fake_run(cmd, timeout=45): + def fake_run(cmd, timeout=45, env=None): query = cmd[-1] queries.append(query) if "stack {" in query: @@ -158,7 +322,7 @@ def test_ci_badge_a_rerun_in_progress_is_pending_not_the_old_result(): def test_fetch_does_not_hide_other_errors(monkeypatch): monkeypatch.setattr(devdash, "HAS_STACK", True) - def fake_run(cmd, timeout=45): + def fake_run(cmd, timeout=45, env=None): raise RuntimeError("gh: HTTP 401: Bad credentials") monkeypatch.setattr(devdash, "run", fake_run) @@ -220,6 +384,19 @@ def test_usage_row_shows_pace_icon_unless_turned_off(show): assert ("<<" in row) is show + +def test_usage_hides_disabled_credentials_but_shows_authenticated_accounts_without_data(): + st = devdash.State() + st.usage = {"reports": [{"provider": "cursor", "limits": []}], + "accountsWithoutUsage": [{"provider": "openai-codex"}], + "disabledCredentials": [{"provider": "anthropic"}]} + text = "\n".join(row.plain for row in devdash.render_usage(st, 50, NOW)) + assert "Cursor" in text + assert "no data: Codex" in text + assert "Anthropic" not in text + assert "disabled credential" not in text + + @pytest.mark.parametrize(("points", "row"), [ (None, " 34% "), (0, " 34% | "),