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
42 changes: 37 additions & 5 deletions fleet/cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""The ``fleet`` command-line interface.

Subcommands:
- ``fleet status [--root DIR] [--filter active|stalled|dead|all]``
- ``fleet status [--root DIR] [--filter active|stalled|dead|stranded|paused|all]``
Scan the root, assess each project, and print a markdown portfolio
table (optionally filtered by health).
table (optionally filtered by health). The table always shows a
``Git`` column summarizing each project's git-side work in flight.
- ``fleet snapshot [--root DIR] [--snapshot FILE]``
Scan the root, assess each project, and save the portfolio as a JSON
snapshot (the baseline that :func:`diff` compares against).
Expand All @@ -19,6 +20,12 @@
rows because ``removed`` rows have no resulting health, and filtering would
hide part of the change set. Use ``status --filter`` for a health view of
the current state.
- ``status --filter`` accepts the v1 classes (``active`` / ``stalled`` /
``dead``) and the two v2-only classes (``stranded`` / ``paused``). The v1
classes match the ``health`` column (v1 classification); ``stranded`` and
``paused`` are selected with the v2 classifier
(:func:`fleet.health.classify_health_v2`) over the project's git state.
Full v2 integration of the ``health`` column is a later cycle.
- Machine-readable (``--json``) output is intentionally not provided yet: no
consumer needs it, and the data model (``ProjectHealth`` / ``DiffRow``)
would serialize trivially if a concrete consumer appears.
Expand All @@ -33,8 +40,10 @@
from fleet import __version__, discover, report
from fleet import health as health_mod
from fleet import snapshot as snapshot_mod
from fleet.gittest import EMPTY_STATE, GitState, read_gitstate
from fleet.health import classify_health_v2

_VALID_FILTERS = ("active", "stalled", "dead", "all")
_VALID_FILTERS = ("active", "stalled", "dead", "stranded", "paused", "all")


def _build_parser() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -104,12 +113,35 @@ def _assess_all(root: str) -> list[health_mod.ProjectHealth]:
return [health_mod.assess(p.name, p.ai_dir) for p in projects]


def _git_states(root: str) -> dict[str, GitState]:
"""Read the git work-in-flight state for every project under *root*.

Returns a mapping of project name -> :class:`~fleet.gittest.GitState`.
Projects that are not git repos (or have no ``main``) map to the empty
state (a clean ``-`` in the ``Git`` column).
"""
return {p.name: read_gitstate(p.path) for p in discover.discover(root)}


def _cmd_status(args: argparse.Namespace) -> int:
"""Run the ``status`` subcommand; return a process exit code."""
healths = _assess_all(args.root)
git_states = _git_states(args.root)
if args.filter != "all":
healths = [h for h in healths if h.health == args.filter]
print(report.render_portfolio(healths))
if args.filter in ("stranded", "paused"):
healths = [
h
for h in healths
if classify_health_v2(
h.days_since_activity,
h.last_outcome,
git_states.get(h.name, EMPTY_STATE),
)
== args.filter
]
else:
healths = [h for h in healths if h.health == args.filter]
print(report.render_portfolio(healths, git_states))
return 0


Expand Down
60 changes: 53 additions & 7 deletions fleet/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
:func:`render_portfolio` turns a list of :class:`~fleet.health.ProjectHealth`
into a one-page markdown table sorted by last-activity descending (projects
with no activity sort last).

By default the table has six columns (Project, Last Cycle, Last Outcome, Days
Since Activity, Open Issues, Health). Passing a ``git_states`` mapping adds a
seventh ``Git`` column at the end summarizing each project's git-side work in
flight (an unmerged ``build*`` branch and/or unpushed commits); a clean
project renders ``-``.
"""

from __future__ import annotations

from fleet.gittest import EMPTY_STATE, GitState
from fleet.health import ProjectHealth

# Health labels in display order (most to least healthy).
Expand All @@ -28,6 +35,25 @@ def _fmt_outcome(outcome: str | None) -> str:
return "-" if outcome is None else outcome


def _fmt_git(gs: GitState) -> str:
"""Format a git work-in-flight summary for the ``Git`` column.

A clean state (no unmerged ``build*`` branch and no unpushed commits)
renders ``-``. Otherwise the parts are joined with ``,``:

- ``unmerged:<b1>+<b2>`` — the unmerged ``build*`` branch names.
- ``unpushed:<n>`` — the count of unpushed commits on ``main``.
"""
if not gs.unmerged_build_branches and gs.unpushed_commits == 0:
return "-"
parts: list[str] = []
if gs.unmerged_build_branches:
parts.append("unmerged:" + "+".join(gs.unmerged_build_branches))
if gs.unpushed_commits > 0:
parts.append("unpushed:" + str(gs.unpushed_commits))
return ",".join(parts)


def _sort_key(h: ProjectHealth) -> tuple[int, float, int, str]:
"""Sort key: last-activity descending, then health, then name.

Expand All @@ -43,13 +69,23 @@ def _sort_key(h: ProjectHealth) -> tuple[int, float, int, str]:
return (time_key, -ts, _HEALTH_ORDER.get(h.health, 3), h.name)


def render_portfolio(healths: list[ProjectHealth]) -> str:
def render_portfolio(
healths: list[ProjectHealth],
git_states: dict[str, GitState] | None = None,
) -> str:
"""Render a markdown portfolio status table.

Parameters
----------
healths:
The per-project health rows to render.
git_states:
Optional mapping of project name -> :class:`~fleet.gittest.GitState`.
When provided, a seventh ``Git`` column is appended at the end of each
row summarizing that project's git-side work in flight (``-`` when
clean, ``unmerged:<b1>+<b2>`` / ``unpushed:<n>`` otherwise). When
``None`` (the default) the output is the six-column table, byte-
identical to the pre-git-column form.

Returns
-------
Expand All @@ -59,18 +95,28 @@ def render_portfolio(healths: list[ProjectHealth]) -> str:
with a single "no projects" row.
"""
rows = sorted(healths, key=_sort_key)
with_git = git_states is not None

header = "| Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health |"
separator = "|---|---|---|---|---|---|"
if with_git:
header += " Git |"
separator += "---|"
lines = [header, separator]

lines = [
"| Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health |",
"|---|---|---|---|---|---|",
]
if not rows:
lines.append("| (no projects discovered) | - | - | - | - | - |")
no_projects = "| (no projects discovered) | - | - | - | - | - |"
if with_git:
no_projects += " - |"
lines.append(no_projects)
return "\n".join(lines)

for h in rows:
lines.append(
row = (
f"| {h.name} | {_fmt_cycle(h.last_cycle)} | {_fmt_outcome(h.last_outcome)} "
f"| {_fmt_days(h.days_since_activity)} | {h.open_issues} | {h.health} |"
)
if git_states is not None:
row += f" {_fmt_git(git_states.get(h.name, EMPTY_STATE))} |"
lines.append(row)
return "\n".join(lines)
85 changes: 80 additions & 5 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import pytest

from fleet import __version__, cli, health, report, snapshot
from fleet.gittest import EMPTY_STATE, GitState, read_gitstate
from tests._fixtures import make_project

NOW = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
Expand Down Expand Up @@ -58,6 +59,17 @@ def _assess_root(root: Path) -> list[health.ProjectHealth]:
return [health.assess(p.name, p.ai_dir, now=NOW) for p in projects]


def _git_states(root: Path) -> dict[str, GitState]:
"""Read the git state for every discovered project under *root*.

Mirrors the CLI's ``_git_states``. The tmp projects are not git repos,
so every value is the empty state (a clean ``-`` in the Git column).
"""
from fleet import discover

return {p.name: read_gitstate(p.path) for p in discover.discover(root)}


def _run_status(root: Path, *extra: str, capsys) -> str:
"""Run `fleet status --root <root> [extra...]` and return captured stdout."""
argv = ["status", "--root", str(root), *extra]
Expand All @@ -75,14 +87,16 @@ def test_cli_status_matches_render_portfolio(tmp_path: Path, capsys) -> None:
assessed = _assess_root(tmp_path)

out = _run_status(tmp_path, capsys=capsys)
assert out == report.render_portfolio(assessed) + "\n"
assert out == report.render_portfolio(assessed, _git_states(tmp_path)) + "\n"


def test_cli_status_filter_active(tmp_path: Path, capsys) -> None:
"""`--filter active` yields only the active project's row."""
_build_root(tmp_path)
assessed = _assess_root(tmp_path)
expected = report.render_portfolio([h for h in assessed if h.health == "active"])
expected = report.render_portfolio(
[h for h in assessed if h.health == "active"], _git_states(tmp_path)
)

out = _run_status(tmp_path, "--filter", "active", capsys=capsys)
assert out == expected + "\n"
Expand All @@ -95,7 +109,9 @@ def test_cli_status_filter_stalled(tmp_path: Path, capsys) -> None:
"""`--filter stalled` yields only the stalled project's row."""
_build_root(tmp_path)
assessed = _assess_root(tmp_path)
expected = report.render_portfolio([h for h in assessed if h.health == "stalled"])
expected = report.render_portfolio(
[h for h in assessed if h.health == "stalled"], _git_states(tmp_path)
)

out = _run_status(tmp_path, "--filter", "stalled", capsys=capsys)
assert out == expected + "\n"
Expand All @@ -108,7 +124,9 @@ def test_cli_status_filter_dead(tmp_path: Path, capsys) -> None:
"""`--filter dead` yields only the dead project's row."""
_build_root(tmp_path)
assessed = _assess_root(tmp_path)
expected = report.render_portfolio([h for h in assessed if h.health == "dead"])
expected = report.render_portfolio(
[h for h in assessed if h.health == "dead"], _git_states(tmp_path)
)

out = _run_status(tmp_path, "--filter", "dead", capsys=capsys)
assert out == expected + "\n"
Expand All @@ -123,11 +141,68 @@ def test_cli_status_filter_all(tmp_path: Path, capsys) -> None:
assessed = _assess_root(tmp_path)

out = _run_status(tmp_path, "--filter", "all", capsys=capsys)
assert out == report.render_portfolio(assessed) + "\n"
assert out == report.render_portfolio(assessed, _git_states(tmp_path)) + "\n"
for name in ("alpha", "beta", "gamma"):
assert name in out


def test_cli_status_filter_stranded(tmp_path: Path, capsys) -> None:
"""`--filter stranded` selects exactly the projects whose v2 class is stranded.

A project is stranded when git work is in flight (an unmerged build*
branch or unpushed commits), regardless of recency. Here only alpha has
an unmerged build branch, so only alpha's row is printed.
"""
_build_root(tmp_path)
assessed = _assess_root(tmp_path)
alpha = next(h for h in assessed if h.name == "alpha")
git_states = {
"alpha": GitState(("build42/x",), 0),
"beta": EMPTY_STATE,
"gamma": EMPTY_STATE,
}

def _fake_read_gitstate(path):
return git_states[Path(path).name]

with mock.patch.object(health, "count_open_issues", side_effect=_issues), mock.patch.object(
health, "datetime", _FakeDatetime
), mock.patch.object(cli, "read_gitstate", side_effect=_fake_read_gitstate):
rc = cli.main(["status", "--root", str(tmp_path), "--filter", "stranded"])
assert rc == 0
out = capsys.readouterr().out
assert out == report.render_portfolio([alpha], git_states) + "\n"
assert "alpha" in out
assert "beta" not in out
assert "gamma" not in out


def test_cli_status_shows_git_column(tmp_path: Path, capsys) -> None:
"""`status` (default filter) always shows the Git column.

With one project given an unmerged build branch, the header gains a
`Git` column and that project's row shows `unmerged:build42/x`.
"""
_build_root(tmp_path)
git_states = {
"alpha": GitState(("build42/x",), 0),
"beta": EMPTY_STATE,
"gamma": EMPTY_STATE,
}

def _fake_read_gitstate(path):
return git_states[Path(path).name]

with mock.patch.object(health, "count_open_issues", side_effect=_issues), mock.patch.object(
health, "datetime", _FakeDatetime
), mock.patch.object(cli, "read_gitstate", side_effect=_fake_read_gitstate):
rc = cli.main(["status", "--root", str(tmp_path)])
assert rc == 0
out = capsys.readouterr().out
assert " Git |" in out
assert "unmerged:build42/x" in out


# ---------------------------------------------------------------------------
# CLI `diff` subcommand
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading