diff --git a/obs-integration/README.md b/obs-integration/README.md new file mode 100644 index 0000000..d2abe5c --- /dev/null +++ b/obs-integration/README.md @@ -0,0 +1,95 @@ +# OBS Integration + +Manage openSUSE Build Service projects and packages from Noctalia. The bar +widget toggles a panel for browsing your projects, checking out packages, +editing metadata and files, and triggering rebuilds on OBS — without leaving +the shell. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `neyfua/obs-integration` | +| Entries | Bar widget: `obs-integrate`; panel: `panel` | + +## Requirements + +Install the openSUSE Build Service `osc` CLI on `PATH` and configure your +credentials in `~/.config/osc/oscrc` (for example with `osc apiservice` setup +or by copying a working `oscrc`). The plugin shells out to `osc` for every +operation, so all authentication stays in your normal OBS configuration. + +## Usage + +Add the `obs-integrate` widget to a bar. Left-click it to open the panel. + +Open the panel directly with: + +```sh +noctalia msg panel-toggle neyfua/obs-integration:panel +``` + +### My Projects + +The panel opens on **My Projects**: every project you maintain, plus every +project where you are listed as a package maintainer on OBS. Search filters the +list, and the sort button toggles A-Z / Z-A order. The pen icon edits the +project metadata (`osc meta prj -e`). + +### Inside a project + +Click a project to list its packages. A package's icon turns **primary** when +that package is checked out in your local directory. Click a package to open +its actions: + +- **Checkout package** — `osc co` in the configured checkout directory + (hidden once the package is already checked out). +- **Edit files** — expandable list of editable sources: `_service` and the + package's `.spec` open in your `$EDITOR`; the `.changes` file runs + `osc vc`. +- **Files** — lists every non-dotfile in the checkout. Each file has a remove + button; below the list, **Add/Remove** runs `osc ar` asynchronously in the + panel. **Commit** runs `osc status` first; if pending changes exist it + opens a terminal with `osc ci` so you can edit the commit message, otherwise + the commit runs asynchronously in the panel. +- **Service Local Run** — runs `osc service r` in the checkout, executing the + package's source-service scripts locally (local side effects: the `_service` + scripts can modify files in the checkout). +- **Service Manual Run** — runs `osc service mr` in the checkout, running the + source-service scripts locally in "manual" mode (local side effects, like + Service Local Run). +- **Service Run All** — runs `osc service ra` in the checkout, running all + source-service scripts locally. Requires a confirmation click (turns + **secondary** with a **check** glyph) before it executes. +- **Service Remote Run** — runs `osc service rr` on the OBS server for the + package on the remote project. No local code executes; the request triggers + server-side service runs, and a confirmation is not required. +- **Rebuild package** — pick an architecture (or **All**) and trigger + `osc rebuild`. Confirmation is required before it runs. +- **Edit package meta** — `osc meta pkg -e` in a terminal. +- **Remove package** — deletes the checkout from disk only (never touches the + OBS project); confirm before it runs. If it was the last package in the + project directory, the directory is removed too. + +### Sticky navigation + +The panel reopens where you left off — inside the same project or package. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `checkout_dir` | `string` | `~/OBS` | Base directory where packages are checked out. Packages land in `//`. | +| `show_label` | `bool` | `false` | Show the "OBS" label next to the bar icon. | + +## Notes + +The plugin runs the `osc` CLI with your existing credentials — it stores +nothing itself beyond a small cache of project/package listings and your last +navigated view. Removal is local-only and never modifies the OBS project. +Most operations (rebuilds, service runs, updates, add/remove) run +asynchronously and display their output directly in the panel. The Commit +operation opens a terminal only when `osc status` reports pending changes; +otherwise, it runs asynchronously in the panel. Confirmation prompts precede +destructive and side-effecting operations: Rebuild package, Remove package, +and Service Run All. diff --git a/obs-integration/panel.luau b/obs-integration/panel.luau new file mode 100644 index 0000000..42fd196 --- /dev/null +++ b/obs-integration/panel.luau @@ -0,0 +1,1498 @@ +--!nonstrict + +local OSC_CONFIG = noctalia.expandPath("~/.config/osc/oscrc") +local CACHE_FILE = "projects_cache.json" +local NAV_FILE = "nav_state.json" + +local projects = {} +local packages = {} +local selectedProject = nil +local selectedPackage = nil +local packageFilter = "" +local projectFilter = "" +local sortAsc = true +local editFilesOpen = false +local filesOpen = false +local filesPackage = nil +local packageFiles = {} +local rebuildArchIndex = 0 +local rebuildRepos = {} +local rebuildRepoIndex = 0 +local rebuildConfirm = false +local runAllConfirm = false +local repoMap = {} +local savedRebuildRepo = nil +local savedRebuildArch = nil +local rebuildLoaded = false +local isCheckingOut = false +local checkoutLog = "" +local isActionLoading = false +local actionStatus = "" +local logSource = "" +local loadPackageFiles +local resolveCheckoutDir +local maintainerMap = {} +local ownedProjects = {} +local projectsLoading = false +local packagesLoading = false +local errorText = "" +local commandLog = "" +local logPackage = nil +local pendingProject = nil +local removeConfirm = false +local removeFileConfirm = false +local removeFileTarget = nil +local restoreProject = nil +local restorePackage = nil +local restoreFilesOpen = false +local restoreFilesPackage = nil +local restoreCommandLog = nil +local restoreErrorText = nil +local restoreLogSource = nil +local saveCache +local saveNav +local maybeVerify + +local ownedDone = false +local oscUser = nil +local oscWhoPending = false + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function commandError(result) + return noctalia.string.trim(result.stderr ~= "" and result.stderr or result.stdout) +end + +local function setCommandLog(result, refreshFiles, emptyMsg) + local err = commandError(result) + if result.exitCode ~= 0 then + errorText = err ~= "" and err or ("Command failed (exit " .. tostring(result.exitCode) .. ")") + commandLog = "" + else + errorText = "" + commandLog = noctalia.string.trim(result.stdout) + if commandLog == "" and emptyMsg then + commandLog = emptyMsg + end + if refreshFiles then + local checkoutDir = resolveCheckoutDir() + loadPackageFiles(checkoutDir .. "/" .. selectedProject .. "/" .. selectedPackage, selectedPackage) + end + end + logPackage = selectedPackage + render() + saveNav() +end + +local function showCommandLog(result) + setCommandLog(result, false) +end + +local function runActionAsync(cmd, statusText, refreshFiles, pkgDir, pkg, emptyMsg, source) + isActionLoading = true + actionStatus = statusText + logSource = source or "" + errorText = "" + commandLog = "" + render() + noctalia.runAsync(cmd, function(result) + isActionLoading = false + if refreshFiles and result.exitCode == 0 then + local dir = pkgDir + if not dir then + local checkoutDir = resolveCheckoutDir() + dir = checkoutDir .. "/" .. selectedProject .. "/" .. selectedPackage + end + loadPackageFiles(dir, pkg or selectedPackage) + end + setCommandLog(result, false, emptyMsg) + end, 60000) +end + +-- OBS project/package identifiers: reject anything that could escape the +-- checkout dir or inject into shell commands (slashes, leading dots, spaces). +local function validName(value) + return type(value) == "string" + and value ~= "" + and not value:find("/", 1, true) + and value:match("^[%w:.+%-_]+$") ~= nil + and value:match("^%.") == nil +end + +resolveCheckoutDir = function() + local checkoutDir = noctalia.getConfig("checkout_dir") + if type(checkoutDir) ~= "string" or checkoutDir:match("^%s*$") then + checkoutDir = "~/OBS" + end + return noctalia.expandPath(checkoutDir) +end + + +-- Estimate the combined width (px) of the two .changes buttons to decide +-- whether they fit side-by-side or must stack. +local function changesButtonsFit(pkg) + -- Rough per-char width at the button font size, plus glyph+padding budget. + local charW = 7 + local base = 16 + 24 + local editW = (#"Edit " + #pkg + #".changes") * charW + base + local updateW = (#"Update " + #pkg + #".changes") * charW + base + return editW + updateW + 8 <= 396 +end + +local function parseLines(stdout) + local out = {} + for line in tostring(stdout or ""):gmatch("[^\n]+") do + line = noctalia.string.trim(line) + if line ~= "" then + table.insert(out, line) + end + end + return out +end + +local function filterList(list, filter, ascending) + local lower = filter:lower() + local filtered + if filter == "" then + filtered = list + else + filtered = {} + for _, name in ipairs(list) do + if name:lower():find(lower, 1, true) then + table.insert(filtered, name) + end + end + end + table.sort(filtered, function(a, b) + if ascending then + return a < b + else + return a > b + end + end) + return filtered +end + +local function configureOsc() + noctalia.runInTerminal("osc version") +end + +local function checkoutPackage(project, pkg) + if not validName(project) or not validName(pkg) then + return + end + local checkoutDir = resolveCheckoutDir() + local pkgPath = checkoutDir .. "/" .. project .. "/" .. pkg + isCheckingOut = true + checkoutLog = noctalia.tr("panel.checking_out") + render() + + noctalia.runAsync( + "cd " .. shellQuote(checkoutDir) .. " && osc co " .. shellQuote(project) .. " " .. shellQuote(pkg), + function(result) + isCheckingOut = false + if result.exitCode == 0 then + errorText = "" + loadPackageFiles(pkgPath, pkg) + else + errorText = commandError(result) + end + render() + saveNav() + end, + 60000 + ) +end + +local function editProjectMeta(project) + if not validName(project) then + return + end + noctalia.runInTerminal("osc meta prj " .. shellQuote(project) .. " -e") +end + +local function editPackageMeta(project, pkg) + if not validName(project) or not validName(pkg) then + return + end + noctalia.runInTerminal("osc meta pkg " .. shellQuote(project) .. " " .. shellQuote(pkg) .. " -e") +end + +local function rebuildPackage(project, pkg, repo, arch) + if not validName(project) or not validName(pkg) then + return + end + local cmd = "osc rebuild " .. shellQuote(project) .. " " .. shellQuote(pkg) + if repo ~= nil and repo ~= "All" and repo ~= "" then + cmd = cmd .. " -r " .. shellQuote(repo) + end + if arch ~= nil and arch ~= "All" and arch ~= "" then + cmd = cmd .. " -a " .. shellQuote(arch) + end + runActionAsync(cmd, noctalia.tr("panel.rebuilding_pkg"), false, nil, nil, nil, "rebuild") +end + +loadPackageFiles = function(pkgDir, pkg) + noctalia.runAsync( + "find " .. shellQuote(pkgDir) .. " -maxdepth 1 -not -name '.*' -printf '%f\\n'", + function(result) + local list = {} + if result.exitCode == 0 then + for _, f in ipairs(parseLines(result.stdout)) do + if f ~= pkg and f ~= ".osc" then + table.insert(list, f) + end + end + end + table.sort(list) + packageFiles = list + render() + end, + 5000 + ) + -- Re-fetch after a short delay so callers that just created the dir (checkout, + -- osc up, service runs) settle into the complete file list. + noctalia.runAsync("sleep 1", function() + noctalia.runAsync( + "find " .. shellQuote(pkgDir) .. " -maxdepth 1 -not -name '.*' -printf '%f\\n'", + function(result) + local list = {} + if result.exitCode == 0 then + for _, f in ipairs(parseLines(result.stdout)) do + if f ~= pkg and f ~= ".osc" then + table.insert(list, f) + end + end + end + table.sort(list) + packageFiles = list + render() + end, + 5000 + ) + end, 2000) +end + +local function loadRebuildArches(project, pkg) + rebuildLoaded = false + noctalia.runAsync("osc results " .. shellQuote(project) .. " " .. shellQuote(pkg), function(result) + local map = {} + if result.exitCode == 0 then + for line in tostring(result.stdout):gmatch("[^\n]+") do + line = noctalia.string.trim(line) + local repo, arch, _, status = line:match("^(%S+)%s+(%S+)%s+(%S+)%s+(%S+)") + if repo ~= nil and arch ~= nil and status ~= "disabled" then + if not map[repo] then + map[repo] = {} + end + local found = false + for _, a in ipairs(map[repo]) do + if a == arch then + found = true + break + end + end + if not found then + table.insert(map[repo], arch) + end + end + end + end + local repos = {} + for repo in pairs(map) do + table.insert(repos, repo) + end + table.sort(repos) + for repo in pairs(map) do + table.sort(map[repo]) + end + repoMap = map + rebuildRepos = repos + local repoIndex = 0 + if savedRebuildRepo then + for i, repo in ipairs(repos) do + if repo == savedRebuildRepo then + repoIndex = i + break + end + end + end + rebuildRepoIndex = repoIndex + local archIndex = 0 + local repoArchs = savedRebuildRepo and map[savedRebuildRepo] or {} + if savedRebuildArch then + for i, arch in ipairs(repoArchs or {}) do + if arch == savedRebuildArch then + archIndex = i + break + end + end + end + rebuildArchIndex = archIndex + rebuildLoaded = true + render() + end, 15000) +end + +local function removeFileLocal(path, pkgDir, pkg) + noctalia.runAsync("rm " .. shellQuote(path), function(result) + if result.exitCode == 0 then + loadPackageFiles(pkgDir, pkg) + else + errorText = commandError(result) + render() + end + end, 15000) +end + +local function editFilePlain(path) + noctalia.runInTerminal("${EDITOR:-nano} " .. shellQuote(path)) +end + +local function editFile(path, pkgDir, pkg, filename) + if not validName(pkg) then + return + end + if filename:match("%.changes$") then + noctalia.runInTerminal("cd " .. shellQuote(pkgDir) .. " && osc vc") + else + editFilePlain(path) + end +end + +local function oscAddRemove(pkgDir) + runActionAsync( + "cd " .. shellQuote(pkgDir) .. " && osc ar", + noctalia.tr("panel.adding_removing"), + true, + pkgDir, + nil, + noctalia.tr("panel.no_changes", { name = selectedPackage }) + ) +end + +local function oscCommit(pkgDir) + local cmd = "cd " .. shellQuote(pkgDir) .. " && osc ci" + isActionLoading = true + actionStatus = noctalia.tr("panel.committing") + logSource = "" + errorText = "" + commandLog = "" + render() + noctalia.runAsync("cd " .. shellQuote(pkgDir) .. " && osc status", function(result) + local output = result.stdout .. "\n" .. result.stderr + local hasChanges = result.exitCode == 0 and noctalia.string.trim(output) ~= "" + isActionLoading = false + if hasChanges then + noctalia.runInTerminal(cmd) + else + noctalia.runAsync(cmd, showCommandLog, 60000) + end + end, 15000) +end + +local function oscUpdate(pkgDir) + runActionAsync( + "cd " .. shellQuote(pkgDir) .. " && osc up", + noctalia.tr("panel.updating_dir"), + true, + pkgDir + ) +end + +local function oscServiceRun(pkgDir) + runActionAsync( + "cd " .. shellQuote(pkgDir) .. " && osc service mr", + noctalia.tr("panel.running_manual_service"), + true, + pkgDir + ) +end + +local function oscServiceRemoteRun(project, pkg) + runActionAsync( + "osc service rr " .. shellQuote(project) .. " " .. shellQuote(pkg), + noctalia.tr("panel.running_remote_service"), + false + ) +end + +local function oscServiceLocalRun(pkgDir) + runActionAsync( + "cd " .. shellQuote(pkgDir) .. " && osc service r", + noctalia.tr("panel.running_local_service"), + true, + pkgDir + ) +end + +local function oscServiceRunAll(pkgDir) + runActionAsync( + "cd " .. shellQuote(pkgDir) .. " && osc service ra", + noctalia.tr("panel.running_all_services"), + true, + pkgDir + ) +end + +local function removePackageLocal(project, pkg) + if not validName(project) or not validName(pkg) then + return + end + local checkoutDir = resolveCheckoutDir() + local projectDir = checkoutDir .. "/" .. project + local pkgDir = projectDir .. "/" .. pkg + local cmd = "rm -rf " .. shellQuote(pkgDir) + .. "\nif ! ls -A " .. shellQuote(projectDir) .. " | grep -q -v '^\\.osc$'; then rm -rf " .. shellQuote(projectDir) .. "; fi" + noctalia.runAsync(cmd, function(result) + if result.exitCode ~= 0 then + errorText = commandError(result) + end + selectedPackage = nil + removeConfirm = false + render() + saveNav() + end, 15000) +end + +local function loadPackages(project) + selectedProject = project + selectedPackage = nil + packageFilter = "" + editFilesOpen = false + filesOpen = false + packageFiles = {} + removeConfirm = false + rebuildConfirm = false + runAllConfirm = false + isCheckingOut = false + checkoutLog = "" + isActionLoading = false + actionStatus = "" + logSource = "" + packages = maintainerMap[project] or {} + + if #packages == 0 and projectsLoading then + pendingProject = project + packagesLoading = true + render() + return + end + + if #packages == 0 and ownedProjects[project] then + packagesLoading = true + render() + noctalia.runAsync("osc ls " .. shellQuote(project), function(result) + packagesLoading = false + if result.exitCode == 0 then + packages = parseLines(result.stdout) + maintainerMap[project] = packages + saveCache() + errorText = "" + elseif errorText == "" then + errorText = commandError(result) + end + render() + end, 15000) + saveNav() + return + end + + packagesLoading = false + errorText = "" + render() + saveNav() +end + +local function cachePath() + local dir, _ = noctalia.pluginDataDir() + if not dir then + return nil + end + return dir .. "/" .. CACHE_FILE +end + +local function navPath() + local dir, _ = noctalia.pluginDataDir() + if not dir then + return nil + end + return dir .. "/" .. NAV_FILE +end + +saveNav = function() + local path = navPath() + if not path then + return + end + noctalia.writeFile(path, noctalia.json.encode({ + project = selectedProject, + package = selectedPackage, + filesOpen = filesOpen, + filesPackage = filesPackage, + commandLog = commandLog, + errorText = errorText, + logSource = logSource, + })) +end +local function readNav() + local path = navPath() + if not path or not noctalia.fileExists(path) then + return nil, nil, false, nil, nil, nil + end + local data, _ = noctalia.readFile(path) + if not data then + return nil, nil, false, nil, nil, nil + end + local parsed, _ = noctalia.json.decode(data) + if type(parsed) ~= "table" then + return nil, nil, false, nil, nil, nil + end + return parsed.project, parsed.package, parsed.filesOpen == true, parsed.filesPackage, parsed.commandLog, + parsed.errorText, parsed.logSource +end + +saveCache = function() + local path = cachePath() + if not path or #projects == 0 then + return + end + local ownedList = {} + for project in pairs(ownedProjects) do + table.insert(ownedList, project) + end + noctalia.writeFile(path, noctalia.json.encode({ + owned = ownedList, + packages = maintainerMap, + })) +end + +local function prefetchOwnedPackages() + for project in pairs(ownedProjects) do + if not maintainerMap[project] then + noctalia.runAsync("osc ls " .. shellQuote(project), function(result) + if result.exitCode == 0 and not maintainerMap[project] then + local list = parseLines(result.stdout) + maintainerMap[project] = list + saveCache() + if selectedProject == project then + packages = list + packagesLoading = false + errorText = "" + render() + end + end + end, 15000) + end + end +end + +local function readCache() + local path = cachePath() + if not path or not noctalia.fileExists(path) then + return false + end + local data, _ = noctalia.readFile(path) + if not data then + return false + end + local parsed, _ = noctalia.json.decode(data) + if type(parsed) ~= "table" then + return false + end + local cachedPkgs = parsed.packages + local cachedOwned = parsed.owned + if type(cachedPkgs) ~= "table" or type(cachedOwned) ~= "table" then + return false + end + for key, list in pairs(cachedPkgs) do + if type(key) ~= "string" or type(list) ~= "table" then + return false + end + end + maintainerMap = cachedPkgs + ownedProjects = {} + for _, project in ipairs(cachedOwned) do + ownedProjects[project] = true + end + projects = {} + local seen = {} + for project in pairs(maintainerMap) do + projects[#projects + 1] = project + seen[project] = true + end + for project in pairs(ownedProjects) do + if not seen[project] then + projects[#projects + 1] = project + end + end + table.sort(projects) + return true +end + +local function finalize() + local merged = {} + for project in pairs(ownedProjects) do + merged[project] = true + end + for project in pairs(maintainerMap) do + merged[project] = true + end + projects = {} + for project in pairs(merged) do + table.insert(projects, project) + end + table.sort(projects) + + saveCache() + + local deferred = pendingProject + pendingProject = nil + projectsLoading = false + if deferred then + loadPackages(deferred) + return + end + render() + prefetchOwnedPackages() +end + +local function loadProjects(clearNav) + readCache() + + selectedProject = nil + selectedPackage = nil + packages = {} + projectFilter = "" + pendingProject = nil + projectsLoading = true + errorText = "" + ownedDone = false + oscUser = nil + oscWhoPending = false + + if restoreProject then + local proj = restoreProject + local pkg = restorePackage + local filesOpenRestore = restoreFilesOpen + local filesPackageRestore = restoreFilesPackage + restoreProject = nil + restorePackage = nil + restoreFilesOpen = false + restoreFilesPackage = nil + if proj and (maintainerMap[proj] or ownedProjects[proj]) then + projectsLoading = false + loadPackages(proj) + if pkg then + selectedPackage = pkg + filesOpen = filesOpenRestore + filesPackage = filesPackageRestore + if restoreCommandLog ~= nil then + commandLog = restoreCommandLog + end + if restoreErrorText ~= nil then + errorText = restoreErrorText + end + if restoreLogSource ~= nil then + logSource = restoreLogSource + end + render() + saveNav() + loadRebuildArches(proj, pkg) + if filesOpen then + local checkoutDir = resolveCheckoutDir() + loadPackageFiles(checkoutDir .. "/" .. proj .. "/" .. pkg, pkg) + end + return + end + end + end + + render() + if clearNav then + saveNav() + end + + noctalia.runAsync("osc search --project --maintainer --csv", function(result) + if result.exitCode == 0 then + ownedProjects = {} + for _, line in ipairs(parseLines(result.stdout)) do + ownedProjects[line] = true + end + end + ownedDone = true + maybeVerify() + end) + + if oscUser == nil then + oscWhoPending = true + noctalia.runAsync("osc who | cut -d: -f1", function(userResult) + if userResult.exitCode == 0 then + oscUser = noctalia.string.trim(userResult.stdout) + else + oscUser = "" + local err = commandError(userResult) + if err ~= "" then + errorText = err + end + end + oscWhoPending = false + maybeVerify() + end, 15000) + end +end + +maybeVerify = function() + if not ownedDone then + return + end + + if oscWhoPending then + return + end + + if oscUser == nil then + finalize() + return + end + + if oscUser == "" then + finalize() + return + end + + local url = "/search/package_id?match=person/@userid='" .. oscUser .. "' and person/@role='maintainer'" + noctalia.runAsync("osc api " .. shellQuote(url), function(verifyResult) + if verifyResult.exitCode == 0 then + maintainerMap = {} + for line in tostring(verifyResult.stdout):gmatch("[^\n]+") do + local project, name = line:match("