aboutsummaryrefslogtreecommitdiffstats
path: root/sb_builder
diff options
context:
space:
mode:
Diffstat (limited to 'sb_builder')
-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
6 files changed, 122 insertions, 10 deletions
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: