diff options
Diffstat (limited to 'sourcehut-builds')
| -rw-r--r-- | sourcehut-builds/bar.luau | 98 | ||||
| -rw-r--r-- | sourcehut-builds/constants.luau | 49 | ||||
| -rw-r--r-- | sourcehut-builds/date_utils.luau | 16 | ||||
| -rw-r--r-- | sourcehut-builds/empty_state.luau | 9 | ||||
| -rw-r--r-- | sourcehut-builds/error_utils.luau | 8 | ||||
| -rw-r--r-- | sourcehut-builds/panel.luau | 204 | ||||
| -rw-r--r-- | sourcehut-builds/plugin.toml | 79 | ||||
| -rw-r--r-- | sourcehut-builds/poller.luau | 121 | ||||
| -rw-r--r-- | sourcehut-builds/string_utils.luau | 5 | ||||
| -rw-r--r-- | sourcehut-builds/table_utils.luau | 11 | ||||
| -rw-r--r-- | sourcehut-builds/translations/en.json | 59 |
11 files changed, 659 insertions, 0 deletions
diff --git a/sourcehut-builds/bar.luau b/sourcehut-builds/bar.luau new file mode 100644 index 0000000..862287d --- /dev/null +++ b/sourcehut-builds/bar.luau @@ -0,0 +1,98 @@ +--!nonstrict +-- Sourcehut Builds - Bar widget that toggles the build status panel and shows the status of recent tasks. +-- +-- You can configure the glyphs shown for the different statuses, as well as +-- when there are no recent builds. + +local K = require("./constants.luau") + +-------------------------------------------------------------------------------- +-- Constants +-------------------------------------------------------------------------------- +local GLYPHS = { + default = "glyph", + ok = "ok_glyph", + failed = "failed_glyph", + inProgress = "in_progress_glyph", +} +local STYLE = { + color = { + closed = "on_surface", + open = "primary" + } +} + +-------------------------------------------------------------------------------- +-- State +-------------------------------------------------------------------------------- +local isPanelOpen: boolean = false +local buildState: SourcehutBuildsState = require("./empty_state.luau") + +-------------------------------------------------------------------------------- +-- Internal Functions +-------------------------------------------------------------------------------- + +------------------------------------------------------------------ Logic Helpers + +local function hasRecentBuilds(): boolean + return buildState.successful > 0 or buildState.failed > 0 or buildState.inProgress > 0 +end + +----------------------------------------------------------------------------- UI + +-- Renders the bar +local function render() + barWidget.setGlyphColor(isPanelOpen and STYLE.color.open or STYLE.color.closed) + + barWidget.setTooltip(noctalia.tr("bar.tooltip")) + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center" }, { + not hasRecentBuilds() and ui.glyph({ name = noctalia.getConfig(GLYPHS.default), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + buildState.successful > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.ok), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + buildState.failed > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.failed), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + buildState.inProgress > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.inProgress), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil, + })) +end + + +---------------------------------------------------------------- Event Listeners + +local function onPanelStateChanged(value: boolean) + isPanelOpen = value + render() +end + +local function onBuildsStateChanged(value: SourcehutBuildsState) + buildState = value + render() +end + +---------------------------------------------------------------------- Lifecycle + +local function run() + noctalia.setUpdateInterval(K.bar.update_interval) +end + +-------------------------------------------------------------------------------- +-- Listeners +-------------------------------------------------------------------------------- + +noctalia.state.watch(K.events.panel_state_updated, onPanelStateChanged) +noctalia.state.watch(K.events.builds_state_updated, onBuildsStateChanged) + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- +function update() + render() +end + +function onClick() + noctalia.togglePanel(K.panel.id) +end + +function onConfigChanged() +end + +run() diff --git a/sourcehut-builds/constants.luau b/sourcehut-builds/constants.luau new file mode 100644 index 0000000..5133849 --- /dev/null +++ b/sourcehut-builds/constants.luau @@ -0,0 +1,49 @@ +-------------------------------------------------------------------------------- +-- Events +-------------------------------------------------------------------------------- +local CONSTANTS = { + events = { + panel_state_updated = "sourcehut_panel_state_updated", + builds_state_updated = "sourcehut_builds_state_updated", + poller_state_updated = "sourcehut_poller_state_updated" + }, + panel = { + id = "rbdr/sourcehut-builds:panel", + theme = { + fontSize = { + heading = 18 + }, + fontWeight = { + heading = "bold" + } + } + }, + bar = { + update_interval = 2000 + }, + poller = { + state = { + pending = {type= "pending"}, + ok = {type= "ok"} + }, + update_interval = 30000, + errors = { + request_failed = noctalia.tr("error.request_failed"), + bad_response = noctalia.tr("error.bad_response"), + unauthorized = noctalia.tr("error.unauthorized") + }, + cutoff_configuration_key = "recent_cutoff", + build_count_configuration_key = "build_count" + }, + sourcehut = { + builds_url = "https://builds.sr.ht/query", + web_url = "https://builds.sr.ht/", + query = "{\"query\": \"query { me { canonicalName jobs { results { id created updated status note tags tasks { name status } } } } }\"}", + token_configuration_key = "sourcehut_api_token", + errors = { + unauthorized = "ERR_UNAUTHORIZED" + } + } +} + +return CONSTANTS diff --git a/sourcehut-builds/date_utils.luau b/sourcehut-builds/date_utils.luau new file mode 100644 index 0000000..be47b3a --- /dev/null +++ b/sourcehut-builds/date_utils.luau @@ -0,0 +1,16 @@ +return { + parseDate = function(dateString: string): number + local pattern = "(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+).(%d+)" + local runyear, runmonth, runday, + runhour, runminute, runseconds = dateString:match(pattern) + + return os.time({ + year = runyear, + month = runmonth, + day = runday, + hour = runhour, + min = runminute, + sec = runseconds + }) + end +} diff --git a/sourcehut-builds/empty_state.luau b/sourcehut-builds/empty_state.luau new file mode 100644 index 0000000..259c607 --- /dev/null +++ b/sourcehut-builds/empty_state.luau @@ -0,0 +1,9 @@ +local buildState: SourcehutBuildsState = { + successful = 0, + failed = 0, + inProgress = 0, + user = "", + builds = {} +} + +return buildState diff --git a/sourcehut-builds/error_utils.luau b/sourcehut-builds/error_utils.luau new file mode 100644 index 0000000..2969a54 --- /dev/null +++ b/sourcehut-builds/error_utils.luau @@ -0,0 +1,8 @@ +local K = require("./constants.luau") + +return { + isUnauthorized = function(error): boolean + return type(error.extensions) == "table" + and error.extensions.code == K.sourcehut.errors.unauthorized + end +} diff --git a/sourcehut-builds/panel.luau b/sourcehut-builds/panel.luau new file mode 100644 index 0000000..a720721 --- /dev/null +++ b/sourcehut-builds/panel.luau @@ -0,0 +1,204 @@ +--!nonstrict +-- Sourcehut Builds - Panel that shows the +-- +-- You can configure the glyphs shown for the different statuses, as well as +-- when there are no recent builds. + +local EmptyState = require("./empty_state.luau") +local StringUtils = require("./string_utils.luau") +local K = require("./constants.luau") + +-------------------------------------------------------------------------------- +-- State +-------------------------------------------------------------------------------- +local isOpen = false +-- I'm being lazy and not cloning this because my assumption is that I'll always +-- replace the object from the network call and not modify it. But I know I will +-- forget and break that assumption, so this comment is left here because +-- whatever bug you're finding with stale state or bad empty state is caused by +-- not cloning empty state across the files. +local buildState: SourcehutBuildsState = EmptyState +local pollerStatus: PollerStatus = K.poller.state.pending + +-------------------------------------------------------------------------------- +-- Internal Functions +-------------------------------------------------------------------------------- + +------------------------------------------------------------ Translation Helpers + +local function emptyStateLabel(): string + if pollerStatus.type == "pending" then + return noctalia.tr("panel.loading.label") + elseif pollerStatus.type == "error" then + return noctalia.tr("panel.error.label", {error = pollerStatus.error}) + else + return noctalia.tr("panel.empty.label") + end +end + +------------------------------------------------------------------------ Actions + +local function openBuild(build: SourcehutBuild, task: SourcehutBuildTask | nil) + local url = K.sourcehut.web_url .. buildState.user .. '/job/' .. build.id + + if task ~= nil then + url = url .. "#task-" .. task.name + end + + noctalia.runAsync("gio open '" .. url .. "' || xdg-open '" .. url .. "'") +end + + +--------------------------------------------------------------------- UI: Glyphs + +local function buildGlyph(status: SourcehutBuildStatus): string + if status == "PENDING" then + return "circle" + elseif status == "RUNNING" then + return "circle-caret-right" + elseif status == "FAILED" then + return "x" + elseif status == "SUCCESS" then + return "check" + elseif status == "TIMEOUT" then + return "clock-x" + elseif status == "CANCELLED" then + return "circle-off" + end + return "question-mark" +end + +local function taskGlyph(status: SourcehutBuildTaskStatus): string + if status == "PENDING" then + return "circle-dotted" + elseif status == "RUNNING" then + return "circle-caret-right" + elseif status == "FAILED" then + return "x" + elseif status == "SUCCESS" then + return "check" + elseif status == "SKIPPED" then + return "circle-dashed-minus" + end + return "question-mark" +end + +----------------------------------------------------------------- UI: Components + +local function taskButton(build: SourcehutBuild, task: SourcehutBuildTask): UiNode + return ui.button({ + glyph = taskGlyph(task.status), + controlSize = "sm", + variant = "ghost", + tooltip = noctalia.tr("panel.task.tooltip", { + task = task.name, + status = StringUtils.capitalize(task.status), + }), + onClick = function() openBuild(build, task) end + }) +end + +local function buildButton(build: SourcehutBuild): UiNode + return ui.button({ + glyph = buildGlyph(build.status), + text = noctalia.tr("panel.build.label", { + user = buildState.user, + repo = build.tags[1], + id = build.id + }), + controlSize = "sm", + variant = "ghost", + tooltip = noctalia.tr("panel.build.tooltip", { + status = StringUtils.capitalize(build.status), + }), + onClick = function() openBuild(build) end + }) +end + +local function buildRow(build: SourcehutBuild): UiNode + local tasks: {[number]: UiNode} = {} + for _k, task in ipairs(build.tasks) do + tasks[#tasks + 1] = taskButton(build, task) + end + return ui.row({align = "start", justify = "space_between", gap = 2, flexGrow = 1}, { + buildButton(build), + ui.row({justify = "end", gap = 0, flexGrow = 0}, tasks) + }) +end + +local function buildRows(builds: {[number]: SourcehutBuild}): UiNode + local build_rows: {[number]: UiNode} = {} + for _, build in ipairs(builds) do + build_rows[#build_rows+1] = buildRow(build) + end + return ui.column({ flexGrow = 1, align = "top", justify = "start" }, build_rows) +end + +-- Renders the panel +local function render() + local body + local header = ui.row({ align = "center", gap = 8 }, { + ui.label({ + text = noctalia.tr("panel.title"), + fontSize = K.panel.theme.fontSize.heading, + fontWeight = K.panel.theme.fontWeight.heading, + flexGrow = 1 + }), + }) + + if #buildState.builds == 0 then + body = ui.column({ flexGrow = 1, align = "center", justify = "center" }, { + ui.label({ text = emptyStateLabel(), color = "on_surface_variant" }), + }) + else + body = buildRows(buildState.builds) + end + + if isOpen then + panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, { header, body })) + end +end + +---------------------------------------------------------------- Event Listeners + +local function onPollerStateChanged(value: PollerStatus) + pollerStatus = value + render() +end + +local function onBuildsStateChanged(value: SourcehutBuildsState) + buildState = value + render() +end + +-------------------------------------------------------------------------------- +-- Listeners +-------------------------------------------------------------------------------- + +-- Synchronizes the panel state +noctalia.state.watch(K.events.poller_state_updated, onPollerStateChanged) +noctalia.state.watch(K.events.builds_state_updated, onBuildsStateChanged) + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- + +function onOpen(_context) + isOpen = true + noctalia.state.set(K.events.panel_state_updated, isOpen) + render() +end + +function onClose() + isOpen = false + noctalia.state.set(K.events.panel_state_updated, isOpen) +end + +function onToggle(value: boolean) + isOpen = value + render() +end + +function onConfigChanged() + render() +end diff --git a/sourcehut-builds/plugin.toml b/sourcehut-builds/plugin.toml new file mode 100644 index 0000000..1d443e2 --- /dev/null +++ b/sourcehut-builds/plugin.toml @@ -0,0 +1,79 @@ +id = "rbdr/sourcehut-builds" +name = "Sourcehut Builds" +version = "1.0.0" +plugin_api = 30 +author = "rbdr" +license = "AGPL-3" +dependencies = [] +tags = ["bar", "panel", "development"] +icon = "circle" +description = "A widget that shows your recent sourcehut builds" + +[[setting]] +key = "sourcehut_api_token" +type = "string" +label_key = "settings.sourcehut_api_token.label" +description_key = "settings.sourcehut_api_token.description" +default = "" + +[[setting]] +key = "build_count" +type = "int" +label_key = "settings.build_count.label" +description_key = "settings.build_count.description" +default = 10 +min = 1 +max = 20 + +[[setting]] +key = "recent_cutoff" +type = "int" +label_key = "settings.recent_cutoff.label" +description_key = "settings.recent_cutoff.description" +default = 900 +min = 60 +max = 86400 + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 450 +height = 320 +placement = "attached" +open_near_click = true + +[[service]] +id = "poller" +entry = "poller.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "circle" + + [[widget.setting]] + key = "ok_glyph" + type = "glyph" + label_key = "settings.ok_glyph.label" + description_key = "settings.ok_glyph.description" + default = "check" + + [[widget.setting]] + key = "failed_glyph" + type = "glyph" + label_key = "settings.failed_glyph.label" + description_key = "settings.failed_glyph.description" + default = "x" + + [[widget.setting]] + key = "in_progress_glyph" + type = "glyph" + label_key = "settings.in_progress_glyph.label" + description_key = "settings.in_progress_glyph.description" + default = "circle-caret-right" diff --git a/sourcehut-builds/poller.luau b/sourcehut-builds/poller.luau new file mode 100644 index 0000000..39cfa5b --- /dev/null +++ b/sourcehut-builds/poller.luau @@ -0,0 +1,121 @@ +--!nonstrict +-- Poller for sourcehut, checks the actual poll + +local K = require("./constants.luau") +local ErrorUtils = require("./error_utils.luau") +local DateUtils = require("./date_utils.luau") +local TableUtils = require("./table_utils.luau") + +-------------------------------------------------------------------------------- +-- Internal Functions +-------------------------------------------------------------------------------- + +--------------------------------------------------------------- State Management + +local function notifyStateChange(state: PollerStatus) + noctalia.state.set(K.events.poller_state_updated, state) +end + +local function notifyError(error: string) + notifyStateChange({type = "error", error = error}) +end + +local function notifyBuildsChange(state: SourcehutBuildsState) + noctalia.state.set(K.events.builds_state_updated, state) +end + +------------------------------------------------------------------------ Parsing + +local function parseBuilds(builds: SourcehutBuildResponse) + local successful = 0 + local failed = 0 + local inProgress = 0 + local cutoff = noctalia.getConfig(K.poller.cutoff_configuration_key) + local build_count = noctalia.getConfig(K.poller.build_count_configuration_key) + local now = os.time() + local eligible_builds = TableUtils.trim(builds.data.me.jobs.results, build_count) + for _, job in ipairs(eligible_builds) do + local jobDate = DateUtils.parseDate(job.created) + if now - jobDate > cutoff then + continue + end + if job.status == "SUCCESS" then + successful += 1 + end + if job.status == "FAILED" then + failed += 1 + end + if job.status == "RUNNING" then + inProgress += 1 + end + end + local state = { + successful = successful, + failed = failed, + inProgress = inProgress, + user = builds.data.me.canonicalName, + builds = eligible_builds + } + notifyStateChange(K.poller.state.ok) + notifyBuildsChange(state) +end + +------------------------------------------------------------------------ Network + +local function onBuildsFetched(response: HttpResponse) + if not response.ok then + notifyError(K.poller.errors.request_failed) + return + end + local builds, error = noctalia.json.decode(response.body) + if error or builds == nil then + notifyError(K.poller.errors.bad_response) + return + end + if response.status == 400 then + if type(builds.errors) == "table" then + for _, error_response in ipairs(builds.errors) do + if ErrorUtils.isUnauthorized(error_response) then + notifyError(K.poller.errors.unauthorized) + return + end + end + end + notifyError(K.poller.errors.bad_response) + return + end + parseBuilds(builds) +end + +local function fetchBuilds() + local request: HttpRequest = { + url = K.sourcehut.builds_url, + method = "GET", + body = K.sourcehut.query, + headers = { + "Content-Type: application/json", + "Authorization: Bearer " .. noctalia.getConfig(K.sourcehut.token_configuration_key) + }, + } + noctalia.http(request, onBuildsFetched) +end + +---------------------------------------------------------------------- Lifecycle + +local function run() + noctalia.setUpdateInterval(K.poller.update_interval) + update() +end + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- +function update() + fetchBuilds() +end + +function onConfigChanged() + fetchBuilds() +end + +run() diff --git a/sourcehut-builds/string_utils.luau b/sourcehut-builds/string_utils.luau new file mode 100644 index 0000000..1072bef --- /dev/null +++ b/sourcehut-builds/string_utils.luau @@ -0,0 +1,5 @@ +return { + capitalize = function(str: string): string + return str:lower():gsub("^%l", string.upper) + end +} diff --git a/sourcehut-builds/table_utils.luau b/sourcehut-builds/table_utils.luau new file mode 100644 index 0000000..47feafa --- /dev/null +++ b/sourcehut-builds/table_utils.luau @@ -0,0 +1,11 @@ +return { + trim = function<T>(table: {[number]: T}, count: number): {[number]: T} + local sliced: {[number]: T} = {} + + for i = 1, count or #table, 1 do + sliced[#sliced+1] = table[i] + end + + return sliced + end +} diff --git a/sourcehut-builds/translations/en.json b/sourcehut-builds/translations/en.json new file mode 100644 index 0000000..c56cafa --- /dev/null +++ b/sourcehut-builds/translations/en.json @@ -0,0 +1,59 @@ +{ + "bar": { + "tooltip": "Sourcehut Builds." + }, + "panel": { + "title": "Sourcehut Builds", + "loading": { + "label": "Fetching builds." + }, + "error": { + "label": "Woops: {error}" + }, + "empty": { + "label": "No builds were found." + }, + "build": { + "label": "{user}/{repo} (#{id})", + "tooltip": "{status}" + }, + "task": { + "tooltip": "{task}: {status}" + } + }, + "settings": { + "sourcehut_api_token": { + "label": "Sourcehut API token", + "description": "Your sourcehut API token, with access to read builds" + }, + "build_count": { + "label": "Build Count", + "description": "Number of builds to show." + }, + "recent_cutoff": { + "label": "Cutoff for Recent Builds", + "description": "The amount of time in seconds to consider a build 'recent' for the glyph display." + }, + "glyph": { + "label": "Glyph", + "description": "Glyph to show when there aren't recent builds." + }, + "ok_glyph": { + "label": "OK Glyph", + "description": "Glyph to show when recent builds are OK." + }, + "failed_glyph": { + "label": "Failed Glyph", + "description": "Glyph to show when recent builds have failed" + }, + "in_progress_glyph": { + "label": "In Progress Glyph", + "description": "Glyph to show when a build is in progress" + } + }, + "error": { + "request_failed": "We could not reach sourcehut. Make sure your network is connected and sourcehut is reachable.", + "bad_response": "We weren't able to understand the response. Make sure sourcehut is operating well, or reach out to the maintainer.", + "unauthorized": "Sourcehut did not authorize the request. Make sure you have set a valid API Token." + } +} |