From 766ce6ff1ea584dfe980a5e0b29251768c4c0726 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 21 Oct 2026 11:36:01 +0200 Subject: sbb-remote 0.12.0: named builds, per-package job results, prune - Packages: named packages run slackrepo build, which also updates outdated ones; update on a named item finds only already-built packages. The per-row Update action is gone, Update all stays. - A real slackrepo job reads its items' buildresults rows into the job's results and is failed when any item failed, aborted or was bad: slackrepo itself exits 0. The notification names the failed items; a Build results dialog lists every item with its log and Retry failed, reopenable from the Jobs ribbon. - slackrepo prune: the helper lists built items whose SlackBuild is gone and runs slackrepo remove on them, without an Update all. stats gives the count; Packages gets a Prune action, the Built tile a 'to prune' note. Remove and prune ask for confirmation at Run it. Co-Authored-By: Claude Opus 5.5 --- sb_builder/__init__.py | 2 +- sb_builder/dashboard.py | 2 ++ sb_builder/fmt.py | 17 ++++++++++- sb_builder/jobs.py | 79 +++++++++++++++++++++++++++++++++++++++++++++--- sb_builder/mainwindow.py | 21 +++++++++++-- sb_builder/packages.py | 11 ++++++- 6 files changed, 122 insertions(+), 10 deletions(-) (limited to 'sb_builder') diff --git a/sb_builder/__init__.py b/sb_builder/__init__.py index 87e7f4e..99d4de6 100644 --- a/sb_builder/__init__.py +++ b/sb_builder/__init__.py @@ -16,4 +16,4 @@ """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM.""" -VERSION = "0.11.0" # must match remote/sbb-remote VERSION +VERSION = "0.12.0" # must match remote/sbb-remote VERSION diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py index 0d68d6f..c70258c 100644 --- a/sb_builder/dashboard.py +++ b/sb_builder/dashboard.py @@ -327,6 +327,8 @@ class Dashboard(QWidget): t["Built"].set(NA, "no slackrepo database") else: never = "" if p is None else f" · {num(p['total'] - sr['built'])} never built" + if s.get("prunable"): + never += f" · {num(s['prunable'])} to prune" t["Built"].set(num(sr["built"]), f"of {num(p and p['total'])}{never}") known = [ts for ts in sync.values() if ts is not None] t["Last pull"].set(fmt_age(min(known)) if known else NA, diff --git a/sb_builder/fmt.py b/sb_builder/fmt.py index 5c50628..3676fd0 100644 --- a/sb_builder/fmt.py +++ b/sb_builder/fmt.py @@ -17,10 +17,12 @@ """Text formatting shared by the tabs.""" import time +from collections import Counter NA = "n/a" ICON = {"starting": "…", "running": "▶", "ok": "✔", "failed": "✘", "killed": "■", "lost": "?"} +BAD_RESULTS = ("failed", "aborted", "bad") # slackrepo item results that fail a job def fmt_bytes(n): @@ -54,10 +56,22 @@ def job_title(j): return " ".join([j["kind"], *j["args"]]) +def counts(results): + """'3 ok, 1 failed' from a slackrepo job's {item: result}.""" + return ", ".join(f"{n} {r}" for r, n in Counter(results.values()).most_common()) + + +def failed_items(j): + """The items of a slackrepo job whose result failed it, sorted.""" + return sorted(i for i, r in (j.get("results") or {}).items() if r in BAD_RESULTS) + + def outcome(j): """How an ended job went, as a sentence for its notification.""" took = fmt_duration(j["end"] - j["start"]) if "end" in j and "start" in j else None after = f" after {took}" if took else "" + if failed_items(j): + return f"Failed: {', '.join(failed_items(j))} ({counts(j['results'])}){after}" return {"ok": f"Succeeded in {took}" if took else "Succeeded", "failed": f"Failed with exit code {j.get('exit', '?')}{after}", "killed": f"Killed{after}", @@ -68,4 +82,5 @@ def describe(j): """One-line job summary: icon, kind and args, start time, duration.""" dur = None if "start" not in j else j.get("end", time.time()) - j["start"] return (f"{ICON.get(j['status'], '?')} {job_title(j)} " - f"{fmt_time(j.get('start', j['created']))} {fmt_duration(dur)}") + f"{fmt_time(j.get('start', j['created']))} {fmt_duration(dur)}" + + (f" {counts(j['results'])}" if j.get("results") else "")) diff --git a/sb_builder/jobs.py b/sb_builder/jobs.py index 9bfa2e3..a64bc58 100644 --- a/sb_builder/jobs.py +++ b/sb_builder/jobs.py @@ -17,15 +17,18 @@ """Jobs tab: job list, live log through job-follow, start and kill; the next-step dialog.""" import codecs +import os import re from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QFontDatabase, QTextCursor +from PyQt6.QtGui import QColor, QFontDatabase, QTextCursor from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QInputDialog, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, - QPushButton, QSplitter, QVBoxLayout, QWidget) + QLabel, QPushButton, QSplitter, QVBoxLayout, QWidget) -from .fmt import describe, job_title +from .dashboard import STATUS +from .fmt import describe, failed_items, job_title +from .results import LogViewer from .ui import SP, action, enable ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]") @@ -104,16 +107,80 @@ class NextStep(QDialog): self.run.emit(["sbshadow", "--yes"]) else: args = [a for a in self.job["args"] if a != "--preview"] + if args[0] in ("remove", "prune") and not self.confirm( + "Remove the packages listed in the preview from the repository?"): + return if not self.publish.isChecked(): args.insert(1, "--no-publish") self.run.emit(["slackrepo", *args]) self.accept() +class BuildResultDialog(QDialog): + """After a slackrepo run that failed items: each item's result, its build log, Retry failed.""" + + retry = pyqtSignal(list) # package names + + def __init__(self, remote, job, parent=None): + super().__init__(parent) + self.remote = remote + self.viewers = [] + self.setWindowTitle("Build results: " + job_title(job)) + self.resize(600, 400) + self.list = QListWidget() + for item, result in sorted(job["results"].items(), key=lambda r: (r[1] == "ok", r[0])): + row = QListWidgetItem(f"{item} {result}") + row.setData(Qt.ItemDataRole.UserRole, item) + if result in STATUS: + row.setForeground(QColor(STATUS[result])) + self.list.addItem(row) + self.list.itemDoubleClicked.connect(self.show_log) + self.status = QLabel() + # bad items (no such package) need a fix first: a retry would fail the same way + self.again = sorted({os.path.basename(i) for i, r in job["results"].items() + if r in ("failed", "aborted")}) + log = QPushButton("Log") + log.clicked.connect(lambda: self.show_log(self.list.currentItem())) + self.retry_btn = QPushButton("Retry failed", enabled=bool(self.again)) + self.retry_btn.setToolTip(" ".join(self.again) or "nothing failed or aborted") + self.retry_btn.clicked.connect(self._retry) + close = QPushButton("Close") + close.clicked.connect(self.accept) + bar = QHBoxLayout() + bar.addWidget(log) + bar.addWidget(self.status, 1) + bar.addWidget(self.retry_btn) + bar.addWidget(close) + layout = QVBoxLayout(self) + layout.addWidget(self.list) + layout.addLayout(bar) + self.list.setCurrentRow(0) + + def show_log(self, row): + if not row: + return + item = row.data(Qt.ItemDataRole.UserRole) + + def got(log, err): + if err: + self.status.setText(f"build log: {err}") + return + viewer = LogViewer(item, log, self) + viewer.finished.connect(lambda *_: self.viewers.remove(viewer)) + self.viewers.append(viewer) + viewer.show() + self.remote.call(got, "build-log", item) + + def _retry(self): + self.retry.emit(self.again) + self.accept() + + class JobsTab(QWidget): refresh_requested = pyqtSignal() started = pyqtSignal(str) # id of a job this GUI started preview_requested = pyqtSignal(dict) # a finished preview to take to its next step + results_requested = pyqtSignal(dict) # a slackrepo run that failed items, to show them def __init__(self, remote, parent=None): super().__init__(parent) @@ -130,13 +197,16 @@ class JobsTab(QWidget): self.act_pull.setMenu(self.pull_menu) self.act_run = action(self, "Run it", "media-playback-start", SP.SP_MediaPlay, lambda: self.preview_requested.emit(self.jobs[self.current])) + self.act_results = action(self, "Build results", "dialog-information", + SP.SP_FileDialogDetailedView, + lambda: self.results_requested.emit(self.jobs[self.current])) self.act_kill = action(self, "Kill", "process-stop", SP.SP_BrowserStop, self.kill) self.actions = [self.act_pull, action(self, "Check shadowing", "edit-find", SP.SP_FileDialogContentsView, lambda: self.start("sbshadow", "--no")), action(self, "Rebuild tree", "edit-clear-all", SP.SP_DialogResetButton, self.rebuild_tree), - self.act_run, self.act_kill] + self.act_run, self.act_results, self.act_kill] self.view_actions = [] self.list = QListWidget() @@ -311,3 +381,4 @@ class JobsTab(QWidget): j = self.jobs.get(self.current) enable(self.act_kill, None if j and j["status"] == "running" else "select a running job") enable(self.act_run, None if j and is_preview(j) else "select a finished preview") + enable(self.act_results, None if j and failed_items(j) else "select a build with failed packages") diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py index 4b0aca2..aab6093 100644 --- a/sb_builder/mainwindow.py +++ b/sb_builder/mainwindow.py @@ -27,9 +27,9 @@ from PyQt6.QtWidgets import (QHeaderView, QLabel, QMainWindow, QMessageBox, QPus from . import VERSION, config from .dashboard import Dashboard -from .fmt import job_title, outcome +from .fmt import failed_items, job_title, outcome from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab -from .jobs import JobsTab, NextStep, is_preview +from .jobs import BuildResultDialog, JobsTab, NextStep, is_preview from .packages import PackagesTab from .repository import RepositoryTab from .results import ResultsTab @@ -120,6 +120,7 @@ class MainWindow(QMainWindow): self.jobs_tab.refresh_requested.connect(self.poll_jobs) self.jobs_tab.started.connect(self.mine.add) self.jobs_tab.preview_requested.connect(self.show_next_step) + self.jobs_tab.results_requested.connect(self.show_build_result) self.packages_tab = PackagesTab(self.remote) self.packages_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args)) self.packages_tab.hints_requested.connect(self.open_hints) @@ -221,6 +222,7 @@ class MainWindow(QMainWindow): if err: return self._failed(err) self.dashboard.show_stats(data) + self.packages_tab.set_prunable(data.get("prunable")) def _got_version(self, data, err): if err: @@ -326,6 +328,8 @@ class MainWindow(QMainWindow): self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"]) elif is_preview(j): self.show_next_step(j, auto=True) + elif j["kind"] == "slackrepo" and failed_items(j): + self.show_build_result(j) @staticmethod def _reload(*tabs): @@ -348,6 +352,17 @@ class MainWindow(QMainWindow): else: d.show() + def show_build_result(self, job): + """A slackrepo run's per-package results: logs, Retry failed.""" + if job["id"] in self.next_steps: + self.next_steps[job["id"]].raise_() + return + d = BuildResultDialog(self.remote, job, self) + d.retry.connect(lambda pkgs: self.jobs_tab.start("slackrepo", "build", "--preview", *pkgs)) + d.finished.connect(lambda _r: self.next_steps.pop(job["id"], None)) + self.next_steps[job["id"]] = d + d.show() + def _badge(self, state): self.badge = state i = self.tabs.indexOf(self.jobs_tab) @@ -364,7 +379,7 @@ class MainWindow(QMainWindow): return # nothing changed, nothing failed: no news d = HintResultDialog(self.remote, result, self) d.build_requested.connect( - lambda pkgs: self.jobs_tab.start("slackrepo", "update", "--preview", *pkgs)) + lambda pkgs: self.jobs_tab.start("slackrepo", "build", "--preview", *pkgs)) d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab)) d.finished.connect(lambda _r: self.hint_dialogs.remove(d)) self.hint_dialogs.append(d) diff --git a/sb_builder/packages.py b/sb_builder/packages.py index dbbbd30..c057c35 100644 --- a/sb_builder/packages.py +++ b/sb_builder/packages.py @@ -320,12 +320,14 @@ class PackagesTab(QWidget): self.act_build = { a: action(self, a.capitalize(), icon, fb, lambda a=a: self.act(a)) for a, icon, fb in (("build", "run-build", SP.SP_MediaPlay), - ("update", "system-software-update", SP.SP_BrowserReload), ("rebuild", "view-refresh", SP.SP_BrowserReload), ("remove", "edit-delete", SP.SP_TrashIcon))} + self.act_prune = action(self, "Prune", "edit-clear", SP.SP_TrashIcon, self.prune) + self.set_prunable(None) self.act_new_hint = action(self, "New hint", "document-new", SP.SP_FileIcon, self.new_hint) self.actions = [*self.act_build.values(), action(self, "Update all", "system-upgrade", SP.SP_ArrowUp, self.update_all), + self.act_prune, self.act_new_hint, action(self, "Publish", "document-send", SP.SP_DialogSaveButton, self.publish)] self.view_actions = [ @@ -418,6 +420,13 @@ class PackagesTab(QWidget): def update_all(self): self.start_requested.emit(["slackrepo", "update", "--preview"]) + def set_prunable(self, n): + """How many built packages lost their SlackBuild (stats), None if unknown.""" + enable(self.act_prune, None if n else "no built package lost its SlackBuild") + + def prune(self): + self.start_requested.emit(["slackrepo", "prune", "--preview"]) + def publish(self): if QMessageBox.question(self, "Publish", "Push the package repository to the mirror now?") \ == QMessageBox.StandardButton.Yes: -- cgit v1.2.3