git/orchard/main
updater/render/audit.py
187 lines7 KB last change raw · history
"""The PKGBUILD audit screen: what changed, why it matters, and the diff itself.

The semantic summary comes first because a line diff answers the wrong question.
"pkgver 1.19 -> 1.19, source +1 -1, sums +1 -1" tells you what kind of change it
was; the rendered diff underneath tells you whether you agree with it.
"""

from __future__ import annotations

import difflib

from rich.text import Text

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

SEVERITY_ROLE = {Severity.INFO: "muted", Severity.LOOK: "warn", Severity.BLOCK: "alert"}


def summary_parts(report: Report) -> list[tuple[str, str]]:
    """(text, role) pairs describing the change in PKGBUILD terms."""
    d = report.diff
    parts: list[tuple[str, str]] = []

    def field(label: str, value: str, role: str = "text") -> None:
        parts.append((f"{label} {value}", role))

    if d.pkgver:
        field("pkgver", f"{d.pkgver[0]} → {d.pkgver[1]}", "new")
    if d.pkgrel:
        field("pkgrel", f"{d.pkgrel[0]} → {d.pkgrel[1]}", "new")
    if d.epoch:
        field("epoch", f"{d.epoch[0]} → {d.epoch[1]}", "warn")
    if d.source:
        field("source", d.source.summary(), "warn")
    if d.sums:
        field("sums", d.sums.summary(), "warn")
    if d.depends:
        field("depends", d.depends.summary(), "warn")
    if d.makedepends:
        field("makedepends", d.makedepends.summary(), "warn")
    if d.provides:
        field("provides", d.provides.summary(), "warn")
    if d.validpgpkeys:
        field("pgpkeys", d.validpgpkeys.summary(), "alert")
    if d.funcs_added:
        field("functions added", ", ".join(d.funcs_added), "warn")
    if d.funcs_changed:
        field("functions changed", ", ".join(d.funcs_changed), "text")
    if d.lines_added or d.lines_removed:
        field("PKGBUILD", f"+{d.lines_added} -{d.lines_removed}", "text")
    if not parts:
        parts.append(("no metadata changed", "muted"))
    return parts


def diff_lines(old: str, new: str, *, context: int = 3) -> list[tuple[str, str, str]]:
    """(marker, line_number, text) for a unified diff of two PKGBUILD texts."""
    out: list[tuple[str, str, str]] = []
    old_lines = old.splitlines()
    new_lines = new.splitlines()
    matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
    for group in matcher.get_grouped_opcodes(context):
        if out:
            out.append((" ", "", ""))
        for tag, i1, i2, j1, j2 in group:
            if tag == "equal":
                for offset, line in enumerate(old_lines[i1:i2]):
                    out.append((" ", str(i1 + offset + 1), line))
            else:
                for offset, line in enumerate(old_lines[i1:i2]):
                    out.append(("-", str(i1 + offset + 1), line))
                for offset, line in enumerate(new_lines[j1:j2]):
                    out.append(("+", str(j1 + offset + 1), line))
    return out


def build(
    report: Report,
    *,
    theme: Theme,
    glyphs: Glyphs,
    width: int = 100,
    title: str = "",
    old_text: str = "",
    new_text: str = "",
    limit: int = 40,
    baseline: bool = True,
    into: Screen | None = None,
) -> list[Text]:
    screen = into or Screen(theme=theme, glyphs=glyphs, columns=Columns.for_width(width))
    cols = screen.columns

    screen.header(title or f"{report.package} · AUR")
    role_of = SEVERITY_ROLE

    loud = len(report.loud)
    screen.section(
        "Findings",
        f"{len(report.findings)} item(s)"
        + (f" · {loud} need a look" if loud else " · nothing unusual"),
    )
    for finding in report.findings:
        role = role_of[finding.severity]
        row = screen.row()
        row.span(f"{finding.severity.glyph:<2}", getattr(theme, role))
        row.span("  ")
        row.span(
            truncate(finding.title, cols.ver - row.at - 2),
            getattr(theme, role) if finding.severity is not Severity.INFO else theme.muted,
        )
        if finding.where:
            row.right(truncate(finding.where, 20), theme.muted)
        screen.finish(row)
        if finding.evidence:
            line = screen.row(indent=cols.indent + 4)
            line.span(truncate(finding.evidence, cols.right - line.at), theme.ver)
            screen.finish(line)

    screen.section("Change", "parsed statically · PKGBUILD never sourced")
    if not baseline:
        # an all-additions diff would be technically true and completely useless:
        # it would look like the whole PKGBUILD was rewritten when in fact we have
        # simply never recorded a version to compare against
        screen.add(
            screen.row().span("no baseline recorded for this package yet", theme.muted).done()
        )
        screen.add(
            screen.row()
            .span("it will be recorded after the next successful build", theme.muted)
            .done()
        )
    parts = summary_parts(report) if baseline else []
    row = screen.row()
    for text, role in parts:
        chunk = text + "    "
        if row.at + len(chunk) > cols.right:
            screen.finish(row)
            row = screen.row()
        row.span(chunk, getattr(theme, role) if hasattr(theme, role) else theme.text)
    screen.finish(row)

    if baseline and (old_text or new_text):
        lines = diff_lines(old_text, new_text)
        shown = lines[:limit]
        screen.section(
            "Diff", f"PKGBUILD · +{report.diff.lines_added} -{report.diff.lines_removed}"
        )
        for marker, number, body in shown:
            role = {"+": "ok", "-": "err"}.get(marker, "muted")
            line = screen.row()
            line.span(marker, getattr(theme, role))
            line.span("  ")
            line.span(f"{number:>4}", theme.muted)
            line.span("   ")
            line.span(truncate(body, cols.right - line.at), getattr(theme, role))
            screen.finish(line)
        if len(lines) > len(shown):
            screen.add(
                screen.row()
                .span(f"+ {len(lines) - len(shown)} more diff lines", theme.muted)
                .done()
            )
    elif new_text:
        # show it as a reference listing, with no +/- implying a comparison
        body_lines = new_text.splitlines()
        shown = body_lines[:limit]
        screen.section("PKGBUILD", f"{len(body_lines)} lines · shown as reference")
        for offset, body in enumerate(shown, start=1):
            line = screen.row()
            line.span("  ", theme.rule)
            line.span(f"{offset:>4}", theme.muted)
            line.span("   ")
            line.span(truncate(body, cols.right - line.at), theme.ver)
            screen.finish(line)
        if len(body_lines) > len(shown):
            screen.add(
                screen.row()
                .span(f"+ {len(body_lines) - len(shown)} more lines", theme.muted)
                .done()
            )

    screen.hint("", "[o] open in delta  [a] accept  [s] skip  [q] abort")
    return screen.render()