diff options
| -rw-r--r-- | AGENTS.md | 10 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | sb_builder/hints_tab.py | 117 | ||||
| -rw-r--r-- | tests/test_gui.py | 40 |
4 files changed, 163 insertions, 13 deletions
@@ -33,12 +33,12 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is - Hints are parsed (`shlex`), never sourced. `sb_builder/hints.py` mirrors slackrepo's own rules (`../GITHUB/slackrepo/libexec/functions.d/parsefunctions.sh`); check that file when changing it. -- Hint editing lives in the helper (`hint-update`, `hint-new` jobs run the - helper's own `_hint` command): text edits, `.bak` before each +- Hint editing lives in the helper (`hint-update`, `hint-new`, `fix-current` + jobs run the helper's own `_hint` command): text edits, `.bak` before each write, a final `SBB-RESULT {json}` log line read back with `job-result`. - It replaces mkhint, which stays on the VM for neovim and openvino - (bundled deps, `manifest_packages`) and for the `DELREQUIRES` hints - sbshadow regenerates with `mkhint -F` after every sync, until phase 4b. + The phantom deps list is the helper's `phantom_deps_file`; sbshadow runs + `sbb-remote fix-current-now` after each sync. mkhint stays on the VM only + for neovim (bundled deps, `manifest_packages`), until phase 4b part 2. - Jobs never get input. Any tool run as a job must be non-interactive (`--yes`/`--no` style flags). If a tool needs a flag it lacks, add it in that tool's own repo (e.g. `slackrepo_setup` for sbshadow) rather than @@ -73,8 +73,13 @@ reopens it. **No download** on, `DOWNLOAD="no"` instead; a hint sbshadow wrote, `DELREQUIRES` only, gets the `.info` version lines added). **Delete hint** removes the ticked packages' hints, each kept as a `.bak`, revertable from - the dialog that follows. neovim and openvino are refused for now: their - bundled deps still need mkhint on the VM. + the dialog that follows. **Phantom deps…** keeps the list of deps + -current ships, which SBo packages still require (SBo drops a package once + it is official): add names, or tick suggestions, the required names no + package of the tree has; **Apply to hints** writes them into the + `DELREQUIRES` of every package that requires them (sbshadow does the same + after each sync). neovim is refused for now: its bundled deps still need + mkhint on the VM. - **Results**: slackrepo's last build attempt per package, newest first, filterable by result. **Show build log** (or double-click) opens the end of the log of the package's last attempt (slackrepo keeps one per command: diff --git a/sb_builder/hints_tab.py b/sb_builder/hints_tab.py index fbf8a70..ca16354 100644 --- a/sb_builder/hints_tab.py +++ b/sb_builder/hints_tab.py @@ -21,9 +21,9 @@ import os from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor, QFontDatabase, QKeySequence, QShortcut, QSyntaxHighlighter -from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, - QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QAbstractItemView, QDialog, QHBoxLayout, QLabel, QLineEdit, + QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, + QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) from . import upstream from .dashboard import STATUS @@ -34,7 +34,7 @@ STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error") STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"], "nvchecker error": STATUS["failed"]} COLUMNS = ("", "package", "hint", ".info", "upstream", "state") -HINT_JOBS = ("hint-update", "hint-new") +HINT_JOBS = ("hint-update", "hint-new", "fix-current") class HintsTab(QWidget): @@ -78,7 +78,10 @@ class HintsTab(QWidget): self.act_update = action(self, "Update selected", "document-save", SP.SP_DialogSaveButton, self.update_selected) self.act_delete = action(self, "Delete hint", "edit-delete", SP.SP_TrashIcon, self.delete) - self.actions = [self.act_check, self.act_update, self.act_delete] + self.actions = [self.act_check, self.act_update, self.act_delete, + action(self, "Phantom deps…", "view-list-details", SP.SP_FileDialogListView, + self.phantom)] + self.phantom_dialog = None self.no_dl = action(self, "No download", "network-offline", SP.SP_DriveNetIcon, checkable=True) self.no_dl.setToolTip('Update selected writes DOWNLOAD="no": slackrepo uses the source ' "already in its cache") @@ -212,6 +215,14 @@ class HintsTab(QWidget): self.start_requested.emit(["hint-update", *self.pairs()] + (["--no-dl"] if self.no_dl.isChecked() else [])) + def phantom(self): + if self.phantom_dialog is None: + self.phantom_dialog = d = PhantomDialog(self.remote, self) + d.apply_requested.connect(lambda: self.start_requested.emit(["fix-current"])) + d.finished.connect(lambda _r: setattr(self, "phantom_dialog", None)) + d.show() + self.phantom_dialog.raise_() + def confirm(self, title, text): return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes @@ -332,3 +343,99 @@ class HintResultDialog(QDialog): if pkgs: self.build_requested.emit(pkgs) self.accept() + + +class PhantomDialog(QDialog): + """The -current phantom deps: the list, suggestions from the tree, Apply = fix-current.""" + + apply_requested = pyqtSignal() + + def __init__(self, remote, parent=None): + super().__init__(parent) + self.remote = remote + self.setWindowTitle("Phantom deps: shipped by -current, DELREQUIREd from SBo packages") + self.resize(900, 600) + self.deps = QListWidget(selectionMode=QAbstractItemView.SelectionMode.ExtendedSelection) + self.name = QLineEdit(placeholderText="package name") + self.name.returnPressed.connect(self.add_name) + add = QPushButton("Add") + add.clicked.connect(self.add_name) + remove = QPushButton("Remove") + remove.clicked.connect(self.remove_selected) + self.suggestions = QListWidget() + self.suggestions.itemClicked.connect(lambda item: item.setCheckState( + Qt.CheckState.Unchecked if item.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked)) + add_ticked = QPushButton("Add ticked") + add_ticked.clicked.connect(self.add_ticked) + apply = QPushButton("Apply to hints", default=True) + apply.setToolTip("Start fix-current: the listed deps go into DELREQUIRES of the " + "packages that require them") + apply.clicked.connect(self.apply) + close = QPushButton("Close") + close.clicked.connect(self.accept) + + left = QVBoxLayout() + left.addWidget(QLabel("<b>Listed</b>")) + left.addWidget(self.deps) + left.addWidget(self.name) + row = QHBoxLayout() + row.addWidget(add) + row.addWidget(remove) + left.addLayout(row) + right = QVBoxLayout() + right.addWidget(QLabel("<b>Suggested</b>: required, but no package of the tree has them")) + right.addWidget(self.suggestions) + right.addWidget(add_ticked) + lists = QHBoxLayout() + lists.addLayout(left, 1) + lists.addLayout(right, 2) + bar = QHBoxLayout() + bar.addStretch() + bar.addWidget(close) + bar.addWidget(apply) + layout = QVBoxLayout(self) + layout.addLayout(lists, 1) + layout.addLayout(bar) + self.reload() + + def reload(self): + self.remote.call(self._loaded, "phantom-list") + + def _loaded(self, data, err): + if err: + QMessageBox.warning(self, "Phantom deps", err) + return + self.deps.clear() + self.deps.addItems(data["deps"]) + self.suggestions.clear() + for s in data["suggestions"]: + needed = s["needed_by"] + more = f" (+{len(needed) - 5})" if len(needed) > 5 else "" + item = QListWidgetItem(f"{s['name']} needed by {', '.join(needed[:5])}{more}") + item.setData(Qt.ItemDataRole.UserRole, s["name"]) + item.setToolTip(", ".join(needed)) + item.setFlags(Qt.ItemFlag.ItemIsEnabled) # the click handler toggles: no double toggle + item.setCheckState(Qt.CheckState.Unchecked) + self.suggestions.addItem(item) + + def _change(self, cmd, names): + if names: + self.remote.call(lambda _d, err: self._loaded(None, err) if err else self.reload(), + cmd, *names) + + def add_name(self): + name = self.name.text().strip() + self.name.clear() + self._change("phantom-add", [name] if name else []) + + def add_ticked(self): + self._change("phantom-add", [ + self.suggestions.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.suggestions.count()) + if self.suggestions.item(i).checkState() == Qt.CheckState.Checked]) + + def remove_selected(self): + self._change("phantom-remove", [i.text() for i in self.deps.selectedItems()]) + + def apply(self): + self.apply_requested.emit() + self.accept() diff --git a/tests/test_gui.py b/tests/test_gui.py index ca51c3c..a25be37 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -36,6 +36,7 @@ from PyQt6.QtWidgets import QApplication # noqa: E402 from sb_builder import VERSION, config # noqa: E402 from sb_builder import hints as hintlib # noqa: E402 +from sb_builder import hints_tab # noqa: E402 from sb_builder import packages as pk # noqa: E402 from sb_builder.mainwindow import MainWindow # noqa: E402 from sb_builder.remote import DOWN, MISSING, parse # noqa: E402 @@ -534,7 +535,7 @@ class SmokeTest(unittest.TestCase): "Dashboard": (["Refresh"], []), "Packages": (["Build", "Update", "Rebuild", "Remove", "Update all", "New hint", "Publish"], ["Select visible", "Clear", "Refresh"]), - "Hints": (["Check upstream", "Update selected", "Delete hint"], + "Hints": (["Check upstream", "Update selected", "Delete hint", "Phantom deps…"], ["Select visible", "Clear", "No download", "Refresh"]), "Results": (["Show build log"], ["Refresh"]), "Jobs": (["Pull overlay", "Check shadowing", "Run it", "Kill"], []), @@ -620,6 +621,43 @@ class SmokeTest(unittest.TestCase): self.assertEqual(len(w.hint_dialogs), 1) # Revert lives there self.assertIn("-A=1", w.hint_dialogs[0].diff.toPlainText()) + def test_phantom_deps_dialog(self): + listed = ["rust-opt"] + + def change(args, _stdin): + if args[0] == "phantom-add": + listed.extend(n for n in args[1:] if n not in listed) + else: + listed[:] = [n for n in listed if n not in args[1:]] + return {"deps": list(listed)}, None + w, remote = self.window(**{ + "phantom-list": lambda args, _stdin: ({"deps": list(listed), "suggestions": [ + {"name": n, "needed_by": ["marco", "steam"]} for n in ("jq", "zenity") if n not in listed]}, # sorted, as the helper + None), + "phantom-add": change, "phantom-remove": change, + "job-start": ({"id": "20260101-000001-fix-current"}, None)}) + w.poll_jobs() + tab = w.open_hints() + tab.actions[-1].trigger() + d = tab.phantom_dialog + self.assertEqual(d.deps.item(0).text(), "rust-opt") + self.assertEqual(d.suggestions.count(), 2) + self.assertEqual(d.suggestions.item(1).text(), "zenity needed by marco, steam") + d.suggestions.itemClicked.emit(d.suggestions.item(1)) # tick zenity by its row + d.add_ticked() + self.assertIn(("phantom-add", "zenity"), remote.calls) + self.assertEqual([d.deps.item(i).text() for i in range(d.deps.count())], ["rust-opt", "zenity"]) + self.assertEqual(d.suggestions.count(), 1) # zenity left the suggestions + d.name.setText("spdlog") + d.add_name() + d.deps.item(0).setSelected(True) + d.remove_selected() + self.assertEqual(listed, ["zenity", "spdlog"]) + d.apply() + self.assertIn(("job-start", "fix-current"), remote.calls) + self.assertIsNone(tab.phantom_dialog) + self.assertIn("fix-current", hints_tab.HINT_JOBS) # its end opens the diff dialog + def test_hint_job_end_opens_diff(self): job = dict(JOB, id="20260101-000000-hint-update", kind="hint-update", args=["nuclei=3.12.0"]) w, remote = self.window(jobs=({"jobs": [job]}, None), |
