aboutsummaryrefslogtreecommitdiff
path: root/sourcehut-builds
diff options
context:
space:
mode:
Diffstat (limited to 'sourcehut-builds')
-rw-r--r--sourcehut-builds/bar.luau98
-rw-r--r--sourcehut-builds/constants.luau49
-rw-r--r--sourcehut-builds/date_utils.luau16
-rw-r--r--sourcehut-builds/empty_state.luau9
-rw-r--r--sourcehut-builds/error_utils.luau8
-rw-r--r--sourcehut-builds/panel.luau204
-rw-r--r--sourcehut-builds/plugin.toml79
-rw-r--r--sourcehut-builds/poller.luau121
-rw-r--r--sourcehut-builds/string_utils.luau5
-rw-r--r--sourcehut-builds/table_utils.luau11
-rw-r--r--sourcehut-builds/translations/en.json59
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."
+ }
+}