git/orchard/main
updater/render/review.py
393 lines14 KB last change raw · history
"""The review screen: what is about to happen, before anything happens.

Terse by default. Every row is short, every section states its own totals, and
anything that needs a second line gets one only when it is genuinely a warning
rather than a routine version bump.
"""

from __future__ import annotations

from dataclasses import dataclass, field

from rich.text import Text

from ..audit import Basis, Report, Severity
from ..icons import Glyphs
from ..theme import Theme
from .base import Columns, Screen, human_bytes, truncate


@dataclass
class RepoRow:
    tag: str  # U upgrade | N new | R removed | D downgrade
    name: str
    old: str
    new: str
    size: str = ""

    @property
    def role(self) -> str:
        return {"U": "accent", "N": "ok", "R": "err", "D": "warn"}.get(self.tag, "muted")


@dataclass
class AurRow:
    tag: str  # U upgrade | ! needs a look | !! blocked
    name: str
    old: str
    new: str
    note: str = ""
    loud: bool = False
    details: list[str] = field(default_factory=list)
    #: whether the package could be examined at all. An empty report for a
    #: package with no PKGBUILD is not a clean result, and must not read as one.
    audited: bool = True
    #: read, but with nothing to compare against: a weaker answer than a diff,
    #: and one that must not be presented as the same thing
    uncompared: bool = False

    @property
    def role(self) -> str:
        return "alert" if self.tag == "!!" else ("warn" if self.tag == "!" else "accent")


@dataclass
class VcsRow:
    """A devel package paru would actually rebuild.

    Carries the version transition, because "r634 -> r893" is the whole story:
    a -git package's recorded version moves when upstream moves.
    """

    name: str
    old: str = ""
    new: str = ""


@dataclass
class CheckRow:
    ok: bool
    label: str
    detail: str
    #: a fuller explanation, shown when the row is opened. A check that has one
    #: is worth opening; one that does not is a single fact and should not
    #: pretend to be more.
    details: list[str] = field(default_factory=list)
    #: the command that resolves this check, if there is one. The front end asks
    #: before running anything, and shows this exact string when it does.
    action: list[str] = field(default_factory=list)
    #: said out loud before an action runs
    warning: str = ""


@dataclass
class Step:
    index: str
    command: str
    detail: str
    #: the full breakdown, shown when the step is opened: the exact argv, what
    #: it will act on, and what it does underneath
    details: list[str] = field(default_factory=list)
    #: the ordered commands the step will actually run, as dicts. This is the
    #: thing worth reading: `pacman -Syu` is a summary, not a queue.
    queue: list[dict] = field(default_factory=list)


@dataclass
class Review:
    """Everything the review screen renders. Assembled by the CLI."""

    meta: str = ""
    repo: list[RepoRow] = field(default_factory=list)
    repo_total: int = 0
    repo_download: int = 0
    repo_delta: int = 0
    aur: list[AurRow] = field(default_factory=list)
    aur_total: int = 0
    aur_build: int = 0
    vcs: list[VcsRow] = field(default_factory=list)
    #: (id, severity, fixed, packages, issues) for each advisory
    advisories: list[tuple[str, str, str, str, int]] = field(default_factory=list)
    checks: list[CheckRow] = field(default_factory=list)
    steps: list[Step] = field(default_factory=list)
    row_limit: int = 6
    vcs_limit: int = 8

    @property
    def blocked(self) -> list[AurRow]:
        return [r for r in self.aur if r.tag == "!!"]

    @property
    def loud(self) -> list[AurRow]:
        return [r for r in self.aur if r.loud]


#: Advisory severity, as arch-audit spells it, onto a role. Distinct from
#: SEVERITY_ROLE, which maps the *audit's* own INFO/LOOK/BLOCK vocabulary.
ADVISORY_ROLE = {
    "critical": "alert",
    "high": "alert",
    "medium": "warn",
    "low": "accent",
}


def advisory_role(severity: str) -> str:
    return ADVISORY_ROLE.get((severity or "").strip().lower(), "muted")


def aur_row_from(name: str, old: str, new: str, report: Report | None) -> AurRow:
    """Derive the row's tone from the audit verdict, not from a separate flag."""
    row = AurRow(tag="U", name=name, old=old, new=new)
    if report is None:
        return row
    row.audited = report.audited
    if not report.audited:
        # an empty report for a package with no PKGBUILD is not a clean result
        row.note = "not audited"
        row.details = [report.basis.label]
        return row

    # Read, but with no earlier revision to compare against. Saying nothing
    # would present a weaker check as a full one.
    if report.basis is Basis.NEW_ONLY:
        row.uncompared = True
        row.details = [report.basis.label]

    loud = report.loud
    if not loud:
        # No baseline is the normal state until an update has been recorded
        # once, so it is not a row note -- that would mark every row on a first
        # run. It stays in the detail, where it explains what was checked.
        return row
    worst = report.verdict
    row.tag = "!!" if worst is Severity.BLOCK else "!"
    row.loud = True
    row.note = "will be skipped" if worst is Severity.BLOCK else "needs review"
    row.details = [f"{f.title}" + (f"  ({f.evidence})" if f.evidence else "") for f in loud[:3]]
    return row


# --------------------------------------------------------------------------


def _package_row(
    screen: Screen,
    cols: Columns,
    *,
    tag: str,
    role: str,
    name: str,
    old: str,
    new: str,
    note: str = "",
    note_role: str = "muted",
    size: str = "",
) -> None:
    theme, g = screen.theme, screen.glyphs
    row = screen.row()
    row.span(tag, getattr(theme, role))
    row.span("   ")
    name_limit = max(10, cols.ver - row.at - 4)
    row.span(truncate(name, name_limit), theme.text or None)

    if cols.narrow:
        row.span("  ")
        row.span(old, theme.old)
        row.span(f" {g.arrow} ", theme.muted)
        row.span(new, theme.new)
    else:
        row.right(old, theme.old, col=cols.ver - 5)
        row.span(f"  {g.arrow}  ")
        row.span(truncate(new, 16), theme.new)
        if note:
            row.to(cols.note)
            room = cols.right - cols.note - (len(size) + 2 if size else 0)
            row.span(truncate(note, max(4, room)), getattr(theme, note_role))
    if size:
        row.right(size, theme.muted)
    screen.finish(row)


def build(
    review: Review,
    *,
    theme: Theme,
    glyphs: Glyphs,
    width: int = 100,
    into: Screen | None = None,
    dry_run: bool = False,
) -> list[Text]:
    screen = into or Screen(theme=theme, glyphs=glyphs, columns=Columns.for_width(width))
    cols = screen.columns
    g = glyphs

    if review.meta:
        screen.header(review.meta)

    # -- repository --------------------------------------------------------
    if review.repo:
        meta = f"{review.repo_total or len(review.repo)} packages"
        if review.repo_download:
            meta += f" · {human_bytes(review.repo_download)} to download"
        if review.repo_delta:
            meta += f" · {human_bytes(review.repo_delta, signed=True)} on disk"
        screen.section("Repository", meta)
        for row in review.repo[: review.row_limit]:
            _package_row(
                screen,
                cols,
                tag=row.tag,
                role=row.role,
                name=row.name,
                old=row.old,
                new=row.new,
                size=row.size,
            )
        hidden = (
            review.repo_total - min(len(review.repo), review.row_limit)
            if review.repo_total
            else len(review.repo) - review.row_limit
        )
        if hidden > 0:
            screen.add(screen.row().span(f"+ {hidden} more", theme.muted).done())

    # -- AUR, VCS packages included ----------------------------------------
    # VCS packages are AUR packages: same section, same rules, same ability to
    # be skipped. A separate section implied they were a different kind of thing
    # and could not be acted on.
    if review.aur or review.vcs:
        meta = f"{len(review.aur)} packages"
        if review.vcs:
            meta += f" · {len(review.vcs)} from git"
        if review.aur_build:
            meta += f" · {review.aur_build} to build"
        screen.section("AUR", meta)

        for row in review.aur[: review.row_limit]:
            _package_row(
                screen,
                cols,
                tag=row.tag,
                role=row.role,
                name=row.name,
                old=row.old,
                new=row.new,
                note=row.note,
                note_role=row.role,
            )
            for detail in row.details:
                line = screen.row(indent=cols.indent + 4)
                line.span(f"{g.warn}  ", getattr(theme, row.role))
                line.span(truncate(detail, cols.right - line.at), theme.muted)
                screen.finish(line)
        hidden = (
            review.aur_total - min(len(review.aur), review.row_limit)
            if review.aur_total
            else len(review.aur) - review.row_limit
        )
        if hidden > 0:
            screen.add(screen.row().span(f"+ {hidden} more", theme.muted).done())

        if review.vcs:
            # only devel packages paru would actually rebuild, so these are real
            # updates and read like the rest of the section
            for vcs in review.vcs[: review.vcs_limit]:
                row = screen.row()
                row.span("V   ", theme.warn)
                row.span(truncate(vcs.name, cols.ver - cols.indent - 4), theme.text)
                row.right(vcs.old, theme.old, col=cols.ver - 2)
                row.span("  ")
                row.span(f"{g.arrow}  ", theme.muted)
                row.span(vcs.new, theme.new)
                screen.finish(row)
            if len(review.vcs) > review.vcs_limit:
                screen.add(
                    screen.row()
                    .span(f"+ {len(review.vcs) - review.vcs_limit} more devel updates", theme.muted)
                    .done()
                )

    # -- security ----------------------------------------------------------
    if review.advisories:
        fixable = sum(1 for a in review.advisories if a[2])
        meta = f"{len(review.advisories)} advisories"
        if fixable:
            meta += f" · {fixable} fixable"
        else:
            meta += " · none fixable by updating"
        screen.section("Security", meta)
        # fixable first: an update can only resolve those
        for adv_id, severity, fixed, packages, issues in review.advisories:
            row = screen.row()
            # Severity decides the colour. Fixability is already said in
            # words ("fixed in ..." / "none fixable"), and using the colour for
            # it made Critical and Low the same shade -- the one distinction a
            # security list exists to make.
            role = advisory_role(severity)
            # pad, do not just truncate: an 8-character id followed straight by
            # a package name reads as one word
            row.span(truncate(severity, 12).ljust(12) + " ", getattr(theme, role))
            row.span(truncate(adv_id, 10).ljust(10) + " ", theme.text)
            tail = f"  {issues} CVE(s)" if issues else ""
            row.span(truncate(packages, max(0, cols.right - row.at - len(tail))), theme.ver)
            if tail:
                row.right(tail.strip(), theme.muted)
            screen.finish(row)

    # -- checks ------------------------------------------------------------
    if review.checks:
        screen.section("Checks")
        for check in review.checks:
            row = screen.row()
            row.span(g.done if check.ok else g.warn, theme.ok if check.ok else theme.warn)
            row.span("   ")
            row.span(f"{check.label:<14}", theme.text or None)
            row.span(truncate(check.detail, cols.right - row.at), theme.muted)
            screen.finish(row)

    # -- plan --------------------------------------------------------------
    if review.steps:
        screen.section("Plan")
        # From the content, not a constant: `paru -S --needed` is longer than a
        # fixed column would be, so it runs straight into its own explanation.
        command_width = min(26, max(14, max(len(s.command) for s in review.steps) + 2))
        for step in review.steps:
            row = screen.row()
            row.span(step.index, theme.rule)
            row.span("   ")
            row.span(f"{step.command:<{command_width}}", theme.accent)
            row.span(truncate(step.detail, cols.right - row.at), theme.muted)
            screen.finish(row)

    screen.blank()
    blocked = len(review.blocked)
    if blocked:
        phrase = (
            "1 package needs attention" if blocked == 1 else f"{blocked} packages need attention"
        )
        screen.add(screen.row().span(phrase, theme.alert).done())

    if dry_run:
        # never render a prompt that will not be waited on: it reads as the tool
        # quitting unexpectedly rather than as a deliberate dry run
        screen.add(
            screen.row()
            .span("dry run", theme.accent)
            .span("   nothing has been applied", theme.muted)
            .done()
        )
        screen.add(
            screen.row()
            .span("run ", theme.muted)
            .span("updater", theme.accent)
            .span(" without --dry-run to apply this plan", theme.muted)
            .done()
        )
        return screen.render()

    # deliberately no prompt here: `confirm()` owns it. Rendering it in the
    # screen as well produced two prompts, the second appearing on its own line
    # after the first keystroke.
    return screen.render()