aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-21 11:36:01 +0200
committerDanilo M. <danix@danix.xyz>2026-10-21 11:36:01 +0200
commit766ce6ff1ea584dfe980a5e0b29251768c4c0726 (patch)
treeeaa047f46fc9b59acae745406a116d2e479239ca
parentfb93767351dc0d7d4d4da8068b2f94df6ef44b6d (diff)
downloadsb-builder-766ce6ff1ea584dfe980a5e0b29251768c4c0726.tar.gz
sb-builder-766ce6ff1ea584dfe980a5e0b29251768c4c0726.zip
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 <noreply@anthropic.com>
-rw-r--r--README.md10
-rw-r--r--docs/design/2026-10-20-sb-builder-ui-design.md8
-rwxr-xr-xremote/sbb-remote42
-rw-r--r--sb_builder/__init__.py2
-rw-r--r--sb_builder/dashboard.py2
-rw-r--r--sb_builder/fmt.py17
-rw-r--r--sb_builder/jobs.py79
-rw-r--r--sb_builder/mainwindow.py21
-rw-r--r--sb_builder/packages.py11
-rw-r--r--tests/test_gui.py45
-rw-r--r--tests/test_remote.py46
11 files changed, 256 insertions, 27 deletions
diff --git a/README.md b/README.md
index b93a826..c3737a3 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,7 @@ reopens it.
filter on), Results (one result kind) and Jobs.
- **Packages**: every package of the repository, with built version, hint
and status (`ok`, `failed`, `never`, `outdated`, `newer`: built newer than
- the target, e.g. after a pin left its hint, so Update would downgrade). The
+ the target, e.g. after a pin left its hint, so a build would downgrade). The
hint column says what the hint does: `pin` (sets `VERSION`), `deps` (only
`DELREQUIRES`/`ADDREQUIRES`), `skip`, `other`. Search by name or summary,
or with `cat:`, `maint:`, `req:`, `status:`, `hint:` (`hint:none` for no
@@ -101,8 +101,12 @@ reopens it.
chips, and **orphaned** for the packages SBo lists without a maintainer.
The detail pane shows the package's versions, its dependencies after
hints (dropped ones struck through), what its hint file does, and a link
- to it in Hints. **Build**, **Update**, **Rebuild**, **Remove** and **Update
- all** run a slackrepo `--preview` first; **New hint** creates hints, pinned
+ to it in Hints. **Build** (also updates outdated packages), **Rebuild**,
+ **Remove** and **Update all** run a slackrepo `--preview` first; **Prune**
+ removes built packages whose SlackBuild is gone from the tree (e.g. moved
+ to Slackware), the same removal Update all does, without building. A run
+ whose packages fail is marked failed and opens **Build results**: each
+ package's result, its log, and **Retry failed**; **New hint** creates hints, pinned
at the `.info` version, for the ticked packages without one; **Publish**
pushes the package repository (untick **publish** at Run it to hold a run
back and publish later).
diff --git a/docs/design/2026-10-20-sb-builder-ui-design.md b/docs/design/2026-10-20-sb-builder-ui-design.md
index 80df13c..8d37b60 100644
--- a/docs/design/2026-10-20-sb-builder-ui-design.md
+++ b/docs/design/2026-10-20-sb-builder-ui-design.md
@@ -57,19 +57,21 @@ filter bar first), status bar.
| Tab | Main ribbon | Small ribbon |
|---|---|---|
| Dashboard | Refresh | none |
-| Packages | Build, Update, Rebuild, Remove, Update all, New hint, Publish | Select visible, Clear, Refresh |
+| Packages | Build, Rebuild, Remove, Update all, Prune, New hint, Publish | Select visible, Clear, Refresh |
| Hints | Check upstream, Update selected, Delete hint | Select visible, Clear, No download (checkable), Refresh |
| Results | Show build log | Refresh |
-| Jobs | Pull overlay (menu: one entry per overlay), Check shadowing, Rebuild tree, Run it, Kill | none |
+| Jobs | Pull overlay (menu: one entry per overlay), Check shadowing, Rebuild tree, Run it, Build results, Kill | none |
Disabled reasons (tooltips):
-- Build, Update, Rebuild, Remove: "tick packages first".
+- Build, Rebuild, Remove: "tick packages first".
+- Prune: "no built package lost its SlackBuild".
- New hint: "none of the ticked packages is without a hint".
- Update selected: "tick outdated packages after Check upstream".
- Delete hint: "none of the ticked packages has a hint".
- Show build log: "select a result with a log".
- Run it: "select a finished preview".
+- Build results: "select a build with failed packages".
- Kill: "select a running job".
- Check upstream while running: "checking…".
diff --git a/remote/sbb-remote b/remote/sbb-remote
index ace4fb9..15c0fec 100755
--- a/remote/sbb-remote
+++ b/remote/sbb-remote
@@ -41,7 +41,7 @@ import time
import tomllib
import urllib.request
-VERSION = "0.11.0"
+VERSION = "0.12.0"
DEFAULTS = {
"state_dir": "/var/lib/sb-builder",
"repo_dir": "/var/lib/sbopkg/SBo-danix",
@@ -63,7 +63,7 @@ JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-[a-z]+)*(-\d+)?$")
PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$")
PKGNAME = re.compile(r"^[A-Za-z0-9._+-]+$")
VERSION_ARG = re.compile(r"^[A-Za-z0-9._+-]{1,64}$")
-ACTIONS = ("build", "rebuild", "update", "remove")
+ACTIONS = ("build", "rebuild", "update", "remove", "prune")
PRESET = "/usr/libexec/gpg-preset-passphrase"
ITEM = re.compile(r"^[A-Za-z0-9._+-]+/[A-Za-z0-9._+-]+$") # category/pkg
LOG_TAIL = 512 * 1024 # bytes of the log returned; the failure is at the end
@@ -126,6 +126,27 @@ def kind_sbshadow(cfg, args):
return ["sbshadow", _flag(args)], {}
+def _prunable(cfg):
+ """Built items (slackrepo's packages table) whose SlackBuild is gone from the tree."""
+ db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True)
+ try:
+ items = [i for (i,) in db.execute("select distinct itemid from packages order by itemid")]
+ finally:
+ db.close()
+ return [i for i in items if ITEM.match(i) and not i.startswith("-") and not os.path.isfile(
+ os.path.join(cfg["repo_dir"], i, os.path.basename(i) + ".SlackBuild"))]
+
+
+def _results_since(cfg, start):
+ """{itemid: result} of the slackrepo items that finished since start."""
+ db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True)
+ try:
+ return dict(db.execute("select itemid, result from buildresults"
+ " where cast(time as integer) >= ?", (int(start),)))
+ finally:
+ db.close()
+
+
def kind_slackrepo(cfg, args):
if not args or args[0] not in ACTIONS:
raise Error(f"slackrepo: action must be one of {', '.join(ACTIONS)}")
@@ -141,7 +162,14 @@ def kind_slackrepo(cfg, args):
raise Error(f"slackrepo: no such package: {p}")
if p not in pkgs:
pkgs.append(p)
- if not pkgs and action != "update":
+ if action == "prune":
+ if pkgs:
+ raise Error("slackrepo: prune takes no packages")
+ pkgs = _prunable(cfg) # computed here, never named by the GUI
+ if not pkgs:
+ raise Error("slackrepo: nothing to prune")
+ action = "remove"
+ elif not pkgs and action != "update":
raise Error(f"slackrepo: {action} needs at least one package")
argv = ["slackrepo", f"--repo={cfg['slackrepo_repo']}", action]
if "--preview" in opts:
@@ -360,6 +388,13 @@ def cmd_run(cfg, jid=None):
rc = p.wait()
meta.update(end=time.time(), exit=rc,
status="ok" if rc == 0 else "killed" if rc == -signal.SIGTERM else "failed")
+ if signs(meta["kind"], meta["args"]):
+ # slackrepo exits 0 whatever its items did: their results tell
+ results = _try(lambda: _results_since(cfg, meta["start"]))
+ if results is not None:
+ meta["results"] = results
+ if rc == 0 and set(results.values()) & {"failed", "aborted", "bad"}:
+ meta["status"] = "failed"
write_meta(d, meta)
return None
@@ -502,6 +537,7 @@ def cmd_stats(cfg):
"slackpkg": _slackpkg(),
"packages": _try(lambda: _packages(cfg)),
"orphans": _try(lambda: _orphans(cfg)),
+ "prunable": _try(lambda: len(_prunable(cfg))),
"sync": {o: _try(lambda o=o: int(_git(cfg, "log", "-1", "--format=%ct", "--", o + "/")))
for o in cfg["overlays"]},
"slackrepo": _try(lambda: _slackrepo(cfg)),
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:
diff --git a/tests/test_gui.py b/tests/test_gui.py
index ca415d3..f5894de 100644
--- a/tests/test_gui.py
+++ b/tests/test_gui.py
@@ -468,6 +468,29 @@ class SmokeTest(unittest.TestCase):
self.assertIn("…", text)
self.assertNotIn("pkg " * 40, text)
+ def test_failed_build_shows_results(self):
+ job = dict(JOB, id="20260101-000002-slackrepo", kind="slackrepo", args=["build", "a", "b", "c"])
+ w, remote = self.window(jobs=({"jobs": [dict(job, status="running")]}, None),
+ **{"job-start": ({"id": "20260101-000003-slackrepo"}, None)})
+ w.mine.add(job["id"])
+ w.poll_jobs()
+ results = {"cat/a": "ok", "cat/b": "failed", "cat/c": "aborted", "d": "bad"}
+ remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=125, exit=0,
+ results=results)]}, None)
+ w.poll_jobs()
+ self.assertEqual(w.notifier.sent[-1][1],
+ "Failed: cat/b, cat/c, d (1 ok, 1 failed, 1 aborted, 1 bad) after 2m 5s")
+ d = w.next_steps[job["id"]]
+ self.assertEqual(d.list.item(0).text(), "cat/b failed")
+ d.retry_btn.click()
+ self.assertIn(("job-start", "slackrepo", "build", "--preview", "b", "c"), remote.calls)
+
+ def test_prunable_enables_prune(self):
+ w, _ = self.window(stats=(dict(STATS, prunable=3, slackrepo={"results": {}, "built": 5}), None))
+ w.poll_jobs()
+ self.assertTrue(w.packages_tab.act_prune.isEnabled())
+ self.assertIn("3 to prune", w.dashboard.tiles["Built"].detail.text())
+
def test_slackpkg_dot(self):
old = dict(STATS, slackpkg={"update": None, "upgrade": 1})
w, _ = self.window(stats=(old, None))
@@ -500,7 +523,7 @@ class SmokeTest(unittest.TestCase):
tab.chips["orphaned"].setChecked(False)
started = []
tab.start_requested.connect(started.append)
- tab.act("update")
+ tab.act("build")
self.assertEqual(started, []) # nothing checked, no current row
tab.table.clicked.emit(tab.proxy.index(3, 2)) # a click on a row's values ticks it
self.assertEqual(tab.model.checked, {"curl"})
@@ -508,10 +531,10 @@ class SmokeTest(unittest.TestCase):
self.assertEqual(tab.model.checked, set())
tab.table.setCurrentIndex(tab.proxy.index(1, 1))
self.assertIn("<s>x</s>", tab.detail.text())
- tab.act("update") # highlighted but not ticked: nothing
+ tab.act("build") # highlighted but not ticked: nothing
self.assertEqual(started, [])
tab.model.checked.add("foo")
- tab.act("update")
+ tab.act("build")
tab.model.checked.clear()
select, clear = tab.keys
select.activated.emit()
@@ -521,9 +544,12 @@ class SmokeTest(unittest.TestCase):
tab.model.checked.update({"nuclei", "curl"})
tab.act("build")
tab.update_all()
- self.assertEqual(started, [["slackrepo", "update", "--preview", "foo"],
+ tab.set_prunable(2)
+ tab.act_prune.trigger()
+ self.assertEqual(started, [["slackrepo", "build", "--preview", "foo"],
["slackrepo", "build", "--preview", "curl", "nuclei"],
- ["slackrepo", "update", "--preview"]])
+ ["slackrepo", "update", "--preview"],
+ ["slackrepo", "prune", "--preview"]])
def end_preview(self, job, **answers):
"""A window where job, started by this session, goes from running to its status."""
@@ -717,13 +743,14 @@ class SmokeTest(unittest.TestCase):
self.assertNotIn(("inventory",), remote.calls) # each tab loads on first show
expect = {
"Dashboard": (["Refresh"], []),
- "Packages": (["Build", "Update", "Rebuild", "Remove", "Update all", "New hint", "Publish"],
+ "Packages": (["Build", "Rebuild", "Remove", "Update all", "Prune", "New hint", "Publish"],
["Select visible", "Clear", "Refresh"]),
"Hints": (["Check upstream", "Update selected", "Delete hint", "Phantom deps…"],
["Select visible", "Clear", "No download", "Refresh"]),
"Results": (["Show build log"], ["Refresh"]),
"Repository": ([], ["Refresh"]),
- "Jobs": (["Pull overlay", "Check shadowing", "Rebuild tree", "Run it", "Kill"], []),
+ "Jobs": (["Pull overlay", "Check shadowing", "Rebuild tree", "Run it", "Build results", "Kill"],
+ []),
}
for i in range(w.tabs.count()):
w.tabs.setCurrentIndex(i)
@@ -742,6 +769,8 @@ class SmokeTest(unittest.TestCase):
p, h, j = w.open_packages(), w.open_hints(), w.open_jobs()
for a, why in ((p.act_build["build"], "tick packages first"),
(p.act_new_hint, "none of the ticked packages is without a hint"),
+ (p.act_prune, "no built package lost its SlackBuild"),
+ (j.act_results, "select a build with failed packages"),
(h.act_update, "tick outdated packages after Check upstream"),
(h.act_delete, "none of the ticked packages has a hint"),
(j.act_run, "select a finished preview"), (j.act_kill, "select a running job")):
@@ -872,7 +901,7 @@ class SmokeTest(unittest.TestCase):
self.assertEqual(w.hint_dialogs, [])
w.show_hint_result({"ok": ["nuclei"], "failed": []})
w.hint_dialogs[0].build()
- self.assertIn(("job-start", "slackrepo", "update", "--preview", "nuclei"), remote.calls)
+ self.assertIn(("job-start", "slackrepo", "build", "--preview", "nuclei"), remote.calls)
def test_clear_with_filter_and_current_row(self):
# regression: Clear emitted layoutChanged with no layoutAboutToBeChanged;
diff --git a/tests/test_remote.py b/tests/test_remote.py
index 44d3a90..9f28701 100644
--- a/tests/test_remote.py
+++ b/tests/test_remote.py
@@ -172,6 +172,26 @@ class KindsTest(Base):
with self.subTest(args=args), self.assertRaises(sbb.Error):
sbb.KINDS["slackrepo"](self.cfg, args)
+ def test_slackrepo_prune(self):
+ self.make_pkg("misc/kept")
+ db = sqlite3.connect(self.cfg["slackrepo_db"])
+ db.executescript("""
+ create table packages (pkgnam text primary key, itemid text);
+ insert into packages values ('kept-1-x86_64-1_x', 'misc/kept'),
+ ('gone-1-x86_64-1_x', 'misc/gone'), ('gone-doc-1-noarch-1_x', 'misc/gone'),
+ ('evil-1', '-rf/x');
+ """)
+ db.close()
+ kind = sbb.KINDS["slackrepo"]
+ self.assertEqual(kind(self.cfg, ["prune", "--preview"]),
+ (["slackrepo", "--repo=SBo-danix", "remove", "--preview", "misc/gone"], {}))
+ self.assertEqual(sbb.cmd_stats(self.cfg)["prunable"], 1)
+ with self.assertRaises(sbb.Error):
+ kind(self.cfg, ["prune", "kept"])
+ self.make_pkg("misc/gone")
+ with self.assertRaisesRegex(sbb.Error, "nothing to prune"):
+ kind(self.cfg, ["prune"])
+
def test_publish(self):
self.assertEqual(sbb.KINDS["publish"](self.cfg, []), (["sb-publish"], {}))
with self.assertRaises(sbb.Error):
@@ -749,6 +769,32 @@ class JobsTest(Base):
m = sbb.read_meta(d)
self.assertEqual((m["status"], m["exit"]), ("failed", 3))
+ def slackrepo_job(self, results, args=("build", "a", "b")):
+ jid, d = self.make_job(["sh", "-c", "exit 0"], kind="slackrepo", args=list(args))
+ db = sqlite3.connect(self.cfg["slackrepo_db"])
+ db.execute("create table buildresults (itemid text primary key, time text, result text)")
+ db.execute("insert into buildresults values ('cat/old', '1', 'failed')") # before the job
+ db.executemany("insert into buildresults values (?, ?, ?)",
+ [(i, str(int(time.time()) + 1), r) for i, r in results.items()])
+ db.commit()
+ db.close()
+ sbb.cmd_run(self.cfg, jid)
+ return sbb.read_meta(d)
+
+ def test_run_results_failed(self):
+ m = self.slackrepo_job({"cat/a": "ok", "cat/b": "aborted"})
+ self.assertEqual((m["status"], m["exit"]), ("failed", 0))
+ self.assertEqual(m["results"], {"cat/a": "ok", "cat/b": "aborted"})
+
+ def test_run_results_ok(self):
+ m = self.slackrepo_job({"cat/a": "ok", "cat/b": "skipped"})
+ self.assertEqual((m["status"], m["results"]), ("ok", {"cat/a": "ok", "cat/b": "skipped"}))
+
+ def test_run_preview_no_results(self):
+ m = self.slackrepo_job({"cat/a": "failed"}, args=("build", "--preview", "a"))
+ self.assertEqual(m["status"], "ok")
+ self.assertNotIn("results", m)
+
def test_run_missing_program(self):
jid, d = self.make_job(["/nonexistent/prog"])
sbb.cmd_run(self.cfg, jid)