diff options
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | docs/design/2026-10-20-sb-builder-ui-design.md | 8 | ||||
| -rwxr-xr-x | remote/sbb-remote | 42 | ||||
| -rw-r--r-- | sb_builder/__init__.py | 2 | ||||
| -rw-r--r-- | sb_builder/dashboard.py | 2 | ||||
| -rw-r--r-- | sb_builder/fmt.py | 17 | ||||
| -rw-r--r-- | sb_builder/jobs.py | 79 | ||||
| -rw-r--r-- | sb_builder/mainwindow.py | 21 | ||||
| -rw-r--r-- | sb_builder/packages.py | 11 | ||||
| -rw-r--r-- | tests/test_gui.py | 45 | ||||
| -rw-r--r-- | tests/test_remote.py | 46 |
11 files changed, 256 insertions, 27 deletions
@@ -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) |
