Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ tools, one per forge.

| Module | Holds |
|---|---|
| `prtop/cli.py` | The command: one fetch (`run_check`, one `check_forge` per forge), the choice between the live view and one table, `watch`, the arguments. |
| `prtop/cli.py` | The command: one fetch (`run_check`, one `check_forge` per forge), shared between prtops for 10 seconds (`fetch_report`), the choice between the live view and one table, `watch`, the arguments. |
| `prtop/live.py` | The live view: the frame, the fetch loop, the keys. Pure enough to test with a fake terminal and clock. |
| `prtop/terminal.py` | The real terminal: alternate screen, mouse reporting on POSIX, key reading on POSIX (`termios`) and Windows (`msvcrt`), key decoding. |
| `prtop/report.py` | Marks, Status cells, row order and the table. The accessibility contract lives here. |
| `prtop/model.py` | The vocabulary a forge hands to the report: review states, deploy states, the state-file key and the `repo!number` the PR column shows, `ForgeError`. |
| `prtop/forges/` | One module per forge behind the `Forge` protocol: `gitlab.py` and `github.py` each own every call and field name of their service. `command.py` runs `glab` and `gh`; `pipelines.py` holds what both map production tracking onto: job statuses, the production jobs, the frontier. |
| `prtop/tasks.py` | Task sources: what fills the Task column. |
| `prtop/config.py` | `PRTOP_*` settings, the env file, where files live on each platform. |
| `prtop/state.py` | The state file, written atomically. |
| `prtop/state.py` | The state files and the latest fetch's report, written atomically. |
| `prtop/demo.py` | The made-up report behind `--demo`, and the story its live view plays, one event per fetch. |
| `docs/` | The website, an Astro site. `docs/public/prtop.gif`, shown on the site and in the README, is `prtop --demo -d 4` recorded in a 141×19 terminal. `docs/infographic/how_to_read_prtop.py` draws `docs/public/how-to-read-prtop.png` from the demo's live view. |

Expand Down Expand Up @@ -189,6 +189,28 @@ where GitLab's carry `project_path` and `iid`, and a deployment carries `ref`,
}
```

## The latest fetch's report

Every fetch, whether the live view's, a one-shot table's or `prtop watch`'s,
goes through `fetch_report`. It saves its report to `report.json` beside the
state files, with the settings it ran under (`repr(config)`) and the time it
came back. A fetch asked for less than `FRESH_FOR` (10 seconds) later, under the
same settings, reads that report instead of calling the forges. Several prtops
running at once share their calls that way, whatever their delays, and the one
that reads never moves the merge-detection baseline. The demo neither reads nor
saves it.

```jsonc
{
"config": "Config(state_dir=…, forges=('gitlab', 'github'), …)",
"fetched_at": "<ISO 8601>",
"show_task": true,
"action_needed": false,
// One `Row` per array, `null` for the rule between two blocks.
"rows": [["!", "<title>", "<task>", "<PR cell>", "<Status cell>", "<task url>"], null]
}
```

## Testing

```bash
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ prtop's user-facing changes, newest first. The format follows

## [Unreleased]

### Changed
### Added

- Several prtops running at once share their fetches: the live view, one-shot
tables and `prtop watch`. One about to fetch less than 10 seconds after another
fetched, with the same settings, shows that fetch instead of calling GitLab or
GitHub again. Each fetch saves its table to `report.json` in the state
directory.

### Changed
- On macOS and Linux, the live view keeps the mouse the way htop does: the wheel
does not scroll the terminal's history behind the table. Selecting text takes
Option in iTerm2, Shift in most other terminals.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ When its output is not a terminal (a pipe, a file, a script, an AI agent), prtop
prints the table once and exits. Errors go to stderr, so the output stays one
table.

Several prtops running at once share their fetches: one about to fetch less than
10 seconds after another fetched, with the same settings, shows that fetch
instead of calling GitLab or GitHub again.

## How to read prtop

![prtop's live view, annotated. 1, the mark says whose move it is: ! on you (mergeable, rebase, conflicts, own threads, failed pipeline, prod trigger), ? to look at (changes requested, open threads, unknown status), ~ someone else (awaiting review, draft, blocked), . running (CI, merge check, deploying). 2, links open the merge request, its task and the prod trigger. 3, highlighted rows changed at the last fetch. 4, open merge requests, sorted by mark. 5, merged ones, until production runs them.](docs/public/how-to-read-prtop.png)
Expand All @@ -100,6 +104,7 @@ empty value counts as unset. [`.env.example`](.env.example) lists every setting.
|---|---|---|
| Env file | `~/.config/prtop/.env` | `%APPDATA%\prtop\.env` |
| State, one file per forge | `~/.local/state/prtop/<host>.json` | `%LOCALAPPDATA%\prtop\<host>.json` |
| The latest fetch's table | `~/.local/state/prtop/report.json` | `%LOCALAPPDATA%\prtop\report.json` |

`XDG_CONFIG_HOME` and `XDG_STATE_HOME` replace the base directory on every
platform; `PRTOP_CONFIG_FILE` names the env file outright, and `PRTOP_STATE_DIR`
Expand Down
3 changes: 0 additions & 3 deletions examples/claude-code/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,3 @@ minutes and exits as soon as the table changes, which wakes you. When it exits:
The table lists every merge request and pull request of mine. Say nothing about
the others unless I ask. Keep watching until this task's merge requests are merged and in
production, or until I say stop.

If `prtop` is already open in a terminal of mine, it fetches every 30 seconds;
skip the watch and run `/prs` when I ask.
31 changes: 26 additions & 5 deletions prtop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import time
from collections.abc import Callable
from contextlib import redirect_stderr
from datetime import datetime
from datetime import UTC, datetime, timedelta

from prtop import __version__, live, model
from prtop.config import Config, ConfigError, load_config
Expand All @@ -33,12 +33,16 @@
open_row,
unreachable_row,
)
from prtop.state import load_state, save_state
from prtop.state import load_report, load_state, save_report, save_state
from prtop.tasks import TaskSource, task_source
from prtop.terminal import fullscreen

DEFAULT_WATCH_DELAY = 300
DEFAULT_WATCH_CAP = 7200
# A fetch this recent stands in for a new one, whichever prtop made it, so the
# live view, one-shot tables and watches running together share their calls
# to the forges.
FRESH_FOR = timedelta(seconds=10)


def track_merges(forge: Forge, current: dict, previous: dict, deployments: dict) -> dict:
Expand Down Expand Up @@ -119,6 +123,21 @@ def run_check(config: Config, forges: list[Forge], tasks: TaskSource) -> Report:
return Report(assemble_rows(open_rows, deploy_rows), tasks.shows_column, action_needed)


def fetch_report(
config: Config,
forges: list[Forge],
tasks: TaskSource,
now: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> Report:
"""The latest fetch of a prtop with the same settings when it is under
`FRESH_FOR` old, a fetch of this one's otherwise."""
report = load_report(config, now(), FRESH_FOR)
if report is None:
report = run_check(config, forges, tasks)
save_report(config, report, now())
return report


# ---------- Terminal tab color ----------

def set_tab_color(action_needed: bool) -> None:
Expand Down Expand Up @@ -199,10 +218,12 @@ def watch(
*,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
now: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> int:
"""Check every `delay` seconds and exit as soon as the report changes, so
a backgrounded run re-invokes the agent that started it exactly when
something needs attention. The first check only records a baseline.
something needs attention. The first check only records a baseline. A
check made within 10 seconds of another prtop's fetch reads that fetch.

Exits 0 on a change or after `cap` quiet seconds (with a TIMEOUT line), and
1 when a check fails.
Expand All @@ -211,7 +232,7 @@ def watch(
baseline = None
while True:
try:
report = run_check(config, forges, tasks)
report = fetch_report(config, forges, tasks, now)
except ForgeError as e:
print(f"prtop: {e}", file=sys.stderr)
print("[watch] check failed", flush=True)
Expand Down Expand Up @@ -310,7 +331,7 @@ def main(argv: list[str] | None = None) -> int:
return watch(config, forges, tasks, args.delay, args.cap)

def fetch() -> Report:
return run_check(config, forges, tasks)
return fetch_report(config, forges, tasks)

title, on_report = " + ".join(forge.host for forge in forges), tab_color_hook(config)

Expand Down
6 changes: 6 additions & 0 deletions prtop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
|---|---|---|
| env file | `~/.config/prtop/.env` | `%APPDATA%\prtop\.env` |
| state | `~/.local/state/prtop/<host>.json` | `%LOCALAPPDATA%\prtop\<host>.json` |
| the latest fetch's report | `~/.local/state/prtop/report.json` | `%LOCALAPPDATA%\prtop\report.json` |

`XDG_CONFIG_HOME` and `XDG_STATE_HOME` replace the base directory on every
platform. On Windows the settings roam with the user's profile, while the state
Expand Down Expand Up @@ -64,6 +65,11 @@ def state_file(self, host: str) -> Path:
never reads one forge's MRs as having vanished from the other."""
return self.state_dir / f"{re.sub(r'[^A-Za-z0-9.-]', '_', host)}.json"

@property
def report_file(self) -> Path:
"""The latest fetch's report, shared between the prtops running at once."""
return self.state_dir / "report.json"


def _text(name: str, value: str) -> str:
return value
Expand Down
46 changes: 40 additions & 6 deletions prtop/state.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
"""The state file: what the previous run saw, so this run can tell what merged."""
"""The state directory: per forge, what the previous run saw, so this run can
tell what merged; and the latest fetch's report, which a prtop fetching
seconds later reads instead."""

from __future__ import annotations

import json
import sys
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path

from prtop.config import Config
from prtop.report import Report, Row


def _write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(path)


def load_state(path: Path) -> dict:
if path.exists():
Expand All @@ -19,7 +31,29 @@ def load_state(path: Path) -> dict:

def save_state(path: Path, state: dict) -> None:
state["last_checked"] = datetime.now(UTC).isoformat()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
tmp.replace(path)
_write(path, state)


def save_report(config: Config, report: Report, fetched_at: datetime) -> None:
_write(config.report_file, {
"config": repr(config),
"fetched_at": fetched_at.isoformat(),
"show_task": report.show_task,
"action_needed": report.action_needed,
"rows": report.rows,
})


def load_report(config: Config, now: datetime, max_age: timedelta) -> Report | None:
"""The saved report, when a prtop with the same settings fetched it less
than `max_age` before `now`."""
try:
saved = json.loads(config.report_file.read_text(encoding="utf-8"))
age = now - datetime.fromisoformat(saved["fetched_at"])
rows = [None if row is None else Row(*row) for row in saved["rows"]]
report = Report(rows, saved["show_task"], saved["action_needed"])
same_config = saved["config"] == repr(config)
except (OSError, ValueError, KeyError, TypeError):
return None
# A save from the future is a clock that moved back: its age says nothing.
return report if same_config and timedelta(0) <= age < max_age else None
57 changes: 54 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@
import tempfile
import unittest
from contextlib import nullcontext, redirect_stderr, redirect_stdout
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest import mock

from prtop import model
from prtop.cli import batch, main, parse_args, run_check, show_live, tab_color_hook, watch
from prtop.cli import batch, fetch_report, main, parse_args, run_check, show_live, tab_color_hook, watch
from prtop.config import Config, ConfigError
from prtop.forges import make_forges
from prtop.forges.github import GitHub
from prtop.forges.gitlab import GitLab
from prtop.live import View
from prtop.model import DeployState, ForgeError
from prtop.report import NOTHING_OPEN, Report, plain
from prtop.state import save_report
from prtop.tasks import NoTasks, Task

HOST = "gitlab.example.com"
EPOCH = datetime(2026, 10, 27, 14, 0, tzinfo=UTC)


def entry(iid, status=model.AWAITING_REVIEW, *, approved=False, title=None):
Expand All @@ -46,8 +49,10 @@ def __init__(self, host=HOST):
self.states: dict[str, DeployState] = {}
self.asked_about_merges: list[int] = []
self.merge_check_error: str | None = None
self.listings = 0

def open_merge_requests(self, tasks):
self.listings += 1
if self.error:
raise ForgeError(self.error)
return dict(self.open)
Expand Down Expand Up @@ -262,7 +267,7 @@ def trade(seconds):
out = io.StringIO()
with redirect_stdout(out):
code = watch(self.config, [self.gitlab, self.github], NoTasks(), 300, 900,
sleep=trade, clock=lambda: clock[0])
sleep=trade, clock=lambda: clock[0], now=lambda: EPOCH + timedelta(seconds=clock[0]))
self.assertEqual(code, 0)
self.assertNotIn("TIMEOUT", out.getvalue())
self.assertEqual(out.getvalue().count("no change"), 1)
Expand Down Expand Up @@ -300,9 +305,13 @@ def watch(self, change_after=None, cap=3600):
self.change_after = change_after
out = io.StringIO()
with redirect_stdout(out):
code = watch(self.config, [self.forge], NoTasks(), 300, cap, sleep=self.sleep, clock=lambda: self.now)
code = watch(self.config, [self.forge], NoTasks(), 300, cap,
sleep=self.sleep, clock=lambda: self.now, now=self.wall_clock)
return code, out.getvalue()

def wall_clock(self):
return EPOCH + timedelta(seconds=self.now)

def test_it_exits_with_the_report_as_soon_as_something_changes(self):
code, out = self.watch(change_after=2)
self.assertEqual(code, 0)
Expand All @@ -324,6 +333,42 @@ def broken(deployments):
self.assertEqual(code, 1)
self.assertIn("check failed", out)

def test_a_check_within_10_seconds_of_another_prtops_fetch_reads_that_fetch(self):
self.now = -9
fetch_report(self.config, [self.forge], NoTasks(), self.wall_clock)
self.now = 0
self.watch(cap=300)
self.assertEqual(self.forge.listings, 1 + 1)


class SharedFetch(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.config = Config(state_dir=Path(tmp.name))
self.forge = FakeForge()
self.forge.open = {"frontend!1": entry(1)}

def fetch(self, seconds):
return fetch_report(self.config, [self.forge], NoTasks(), lambda: EPOCH + timedelta(seconds=seconds))

def test_a_fetch_under_10_seconds_old_stands_in_for_a_new_one(self):
first = self.fetch(0)
self.forge.open = {}
self.assertEqual(self.fetch(9.9), first)
self.assertEqual(self.forge.listings, 1)

def test_a_fetch_10_seconds_old_is_made_again(self):
self.fetch(0)
self.forge.open = {}
self.assertEqual(self.fetch(10).text, NOTHING_OPEN)
self.assertEqual(self.forge.listings, 2)

def test_a_fetch_under_other_settings_is_made_again(self):
save_report(Config(state_dir=self.config.state_dir, deploy_tracking=False), Report([], False, False), EPOCH)
self.fetch(0)
self.assertEqual(self.forge.listings, 1)


class Batch(unittest.TestCase):
def setUp(self):
Expand Down Expand Up @@ -412,6 +457,12 @@ def test_output_that_is_not_a_terminal_gets_one_table_of_every_forge(self):
self.assertIn("feat: on gitlab", out.getvalue())
self.assertIn("feat: on github", out.getvalue())

def test_two_tables_asked_for_seconds_apart_share_one_fetch(self):
with redirect_stdout(io.StringIO()):
main([])
main([])
self.assertEqual([forge.listings for forge in self.forges], [1, 1])

def test_watch_checks_every_forge(self):
with mock.patch("prtop.cli.watch", return_value=0) as watching:
self.assertEqual(main(["watch"]), 0)
Expand Down
Loading
Loading