diff --git a/github-prs/README.md b/github-prs/README.md new file mode 100644 index 00000000..b41dc685 --- /dev/null +++ b/github-prs/README.md @@ -0,0 +1,68 @@ +# GitHub Pull Requests + +Track the pull requests that matter to you from the Noctalia bar. The plugin uses your existing GitHub CLI authentication and shows CI, review, draft, and activity status in a compact panel. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `raycursive/github-prs` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `fetch` | + +## Requirements + +- Noctalia plugin API 9 or newer (used for closure-backed panel interactions). +- `gh` on `PATH`, authenticated with `gh auth login`. +- `xdg-open` on `PATH` to open a selected pull request in the default browser. + +## Usage + +Enable `raycursive/github-prs` in **Settings → Plugins**, then add the `bar` entry from **Settings → Bar → Widgets**. Click the widget to open the `panel` entry. The `fetch` service starts automatically and refreshes the configured searches in the background. + +Each **Search rules** item is a GitHub search query fragment. The service prefixes every rule with `is:pr is:open` and, unless the rule already contains `archived:`, adds `archived:false`. Results from all rules are merged and deduplicated. + +Examples: + +| Rule | Matches | +| --- | --- | +| `author:@me` | Pull requests you created. | +| `review-requested:@me org:acme` | Reviews requested from you in one organization. | +| `assignee:@me -repo:acme/legacy draft:false` | Non-draft pull requests assigned to you, excluding one repository. | +| `involves:@me org:acme org:other-org` | Pull requests involving you across two organizations. | + +GitHub's `repo:` qualifier matches exact repositories. Use **Excluded repositories** for plugin-side glob patterns such as `legacy-*` or `acme/private-*`. A pattern without an owner matches repository names in every organization; matching is case-insensitive and `*` matches any number of characters. + +The panel groups pull requests by repository and orders them by latest activity. Select a repository header to collapse or expand it, or select a pull-request title to open it in the browser. Open the panel without the bar widget with: + +```sh +noctalia msg panel-toggle raycursive/github-prs:panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `rules` | `string_list` | `author:@me` | GitHub search fragments; one `gh api graphql` request runs per non-empty rule. | +| `excluded_repositories` | `string_list` | empty | Case-insensitive repository-name or `owner/name` glob patterns removed after fetching. | +| `refresh_interval` | `int` | `180` seconds | Background refresh interval, limited to 30–3600 seconds. | +| `glyph` | `glyph` | `git-pull-request` | Glyph used by this bar-widget instance. | +| `hide_when_zero` | `bool` | `false` | Hides the bar widget when no matching open pull requests remain. | + +## IPC + +The `fetch` service accepts these events: + +```sh +# Fetch all configured search rules immediately. +noctalia msg plugin raycursive/github-prs:fetch all refresh + +# Write status, result count, last update, and any error to the Noctalia log. +noctalia msg plugin raycursive/github-prs:fetch all dump +``` + +## Notes + +- For every configured rule, the service spawns `gh api graphql`, which sends the resulting search query to GitHub using the GitHub CLI's current authentication. The plugin does not read or store a GitHub token itself. +- Selecting a pull request spawns `xdg-open` with the GitHub URL. The plugin does not write files. +- Each rule returns at most 50 rows. The panel reports additional matches as truncated instead of silently presenting the list as complete. +- A partial rule failure is shown alongside successful results. If every rule fails, the last successful in-memory result remains visible until the plugin reloads or a later fetch succeeds. diff --git a/github-prs/bar.luau b/github-prs/bar.luau new file mode 100644 index 00000000..fcff5d7d --- /dev/null +++ b/github-prs/bar.luau @@ -0,0 +1,82 @@ +--!nonstrict +-- Bar [[widget]] entry: glyph + open-PR count, fed entirely by the fetch +-- service through shared state. The glyph tints error-red when fetching +-- itself failed; details remain available in the panel. +-- Clicking anywhere on the capsule toggles the panel — +-- which is why the tree deliberately contains no inline ui.button (inline +-- controls would consume their own clicks). + +local PANEL_ID = "raycursive/github-prs:panel" + +local counts = noctalia.state.get("counts") +local fetchStatus = noctalia.state.get("fetch_status") or "loading" + +local function countText() + if counts ~= nil then + return tostring(counts.total) + end + if fetchStatus == "error" then + return "!" + end + return "…" +end + +local function render() + local hideWhenZero = noctalia.getConfig("hide_when_zero") == true + if hideWhenZero and fetchStatus == "ok" and counts ~= nil and counts.total == 0 then + barWidget.setVisible(false) + return + end + barWidget.setVisible(true) + + local glyph = noctalia.getConfig("glyph") + if type(glyph) ~= "string" or glyph == "" then + glyph = "git-pull-request" + end + local isVertical = barWidget.isVertical() + local container = isVertical and ui.column or ui.row + local layout = { gap = 4, align = "center" } + if isVertical then + layout.paddingV = 4 + else + layout.paddingH = 4 + end + barWidget.render(container(layout, { + ui.glyph({ + name = glyph, + size = 16, + color = fetchStatus == "error" and "error" or "on_surface", + }), + ui.label({ + text = countText(), + color = "on_surface", + }), + })) +end + +-- fetch_status is published last by the service, so one watcher on it sees a +-- consistent prs/counts snapshot; the counts watcher covers same-status updates. +noctalia.state.watch("counts", function(value) + counts = value + render() +end) + +noctalia.state.watch("fetch_status", function(value) + fetchStatus = value + render() +end) + +render() + +function update() + noctalia.setUpdateInterval(5000) + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onConfigChanged() + render() +end diff --git a/github-prs/fetch.luau b/github-prs/fetch.luau new file mode 100644 index 00000000..24bf2825 --- /dev/null +++ b/github-prs/fetch.luau @@ -0,0 +1,352 @@ +--!nonstrict +-- Headless [[service]] entry — the only place that talks to GitHub. +-- +-- On every tick (and on manual refresh) it runs one `gh api graphql` search +-- per configured rule, merges the results (deduped by PR url, sorted by +-- updatedAt desc), and publishes them to the plugin's shared state for the +-- bar widget and panel to render. See the state contract below. +-- +-- State keys published: +-- prs array of { number, title, url, repo, author, isDraft, +-- reviewDecision, ci, updatedAt } — nulls normalized to "" +-- counts { total, failing, pending, approved, changes_requested, drafts } +-- error_message "" when healthy; full-failure error or partial warning +-- last_updated_text preformatted "HH:MM" of the last successful merge +-- rules_count number of configured rules (panel empty-state hint) +-- overflow_count sum of matches beyond the per-rule 50 cap +-- fetch_status "loading" | "ok" | "error" — always set LAST so watchers +-- observe a consistent snapshot +-- State keys consumed: +-- refresh_requested monotonic nonce bumped by the panel's refresh button + +local GH_TIMEOUT_MS = 30000 +local DEFAULT_INTERVAL_S = 180 +local MIN_INTERVAL_S = 30 +local RESULTS_PER_RULE = 50 + +-- Single line and free of single quotes, so argument quoting stays trivial. +local GRAPHQL_QUERY = + "query($q: String!) { search(query: $q, type: ISSUE, first: 50) { issueCount nodes { ... on PullRequest { number title url isDraft reviewDecision updatedAt repository { nameWithOwner } author { login } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } } } } }" + +-- Projects the response down to plain PR records and normalizes the nullable +-- fields (ghost author, no review decision, no CI checks) to "" so Luau never +-- sees nil holes. select(.url != null) drops nodes that failed the inline +-- fragment spread. .count keeps the raw issueCount for the overflow note. +local JQ_PROJECTION = + '{count: .data.search.issueCount, items: [.data.search.nodes[] | select(.url != null) | {number, title, url, isDraft, updatedAt, repo: .repository.nameWithOwner, author: (.author.login // ""), reviewDecision: (.reviewDecision // ""), ci: (.commits.nodes[0].commit.statusCheckRollup.state // "")}]}' + +local fetching = false +local fetchGeneration = 0 +local lastRefreshNonce = noctalia.state.get("refresh_requested") or 0 + +local function quoteShellArgument(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +local function readRules() + local raw = noctalia.getConfig("rules") + local rules = {} + if type(raw) == "table" then + for _, rule in ipairs(raw) do + if type(rule) == "string" then + local trimmed = noctalia.string.trim(rule) + if trimmed ~= "" then + table.insert(rules, trimmed) + end + end + end + end + return rules +end + +local function readExcludedRepositories() + local raw = noctalia.getConfig("excluded_repositories") + local patterns = {} + if type(raw) == "table" then + for _, pattern in ipairs(raw) do + if type(pattern) == "string" then + local trimmed = noctalia.string.trim(pattern) + if trimmed ~= "" then + table.insert(patterns, trimmed:lower()) + end + end + end + end + return patterns +end + +-- Small glob matcher: '*' consumes zero or more characters. Patterns without +-- an owner match the repository basename; patterns containing '/' match the +-- full owner/name, without relying on GitHub search's exact-only repo: +-- qualifier. +local function globMatches(value, pattern) + local valueIndex = 1 + local patternIndex = 1 + local starIndex = nil + local starValueIndex = nil + + while valueIndex <= #value do + local patternChar = pattern:sub(patternIndex, patternIndex) + if patternChar == "*" then + starIndex = patternIndex + starValueIndex = valueIndex + patternIndex += 1 + elseif patternIndex <= #pattern and patternChar == value:sub(valueIndex, valueIndex) then + valueIndex += 1 + patternIndex += 1 + elseif starIndex ~= nil then + starValueIndex += 1 + valueIndex = starValueIndex + patternIndex = starIndex + 1 + else + return false + end + end + + while pattern:sub(patternIndex, patternIndex) == "*" do + patternIndex += 1 + end + return patternIndex > #pattern +end + +local function repositoryExcluded(repo, patterns) + local fullName = repo:lower() + local basename = fullName:match("[^/]+$") or fullName + for _, pattern in ipairs(patterns) do + local candidate = pattern:find("/", 1, true) and fullName or basename + if globMatches(candidate, pattern) then + return true + end + end + return false +end + +local function filterExcluded(prs, patterns) + local filtered = {} + for _, pr in ipairs(prs) do + if not repositoryExcluded(pr.repo, patterns) then + table.insert(filtered, pr) + end + end + return filtered +end + +local function readIntervalMs() + local interval = tonumber(noctalia.getConfig("refresh_interval")) or DEFAULT_INTERVAL_S + return math.max(MIN_INTERVAL_S, interval) * 1000 +end + +local function buildSearchQuery(rule) + local q = "is:pr is:open" + if not rule:find("archived:", 1, true) then + q ..= " archived:false" + end + return q .. " " .. rule +end + +local function buildCommand(rule) + return "gh api graphql -f query=" + .. quoteShellArgument(GRAPHQL_QUERY) + .. " -f q=" + .. quoteShellArgument(buildSearchQuery(rule)) + .. " --jq " + .. quoteShellArgument(JQ_PROJECTION) +end + +local function computeCounts(prs) + local counts = { total = #prs, failing = 0, pending = 0, approved = 0, changes_requested = 0, drafts = 0 } + for _, pr in ipairs(prs) do + if pr.ci == "FAILURE" or pr.ci == "ERROR" then + counts.failing += 1 + elseif pr.ci == "PENDING" or pr.ci == "EXPECTED" then + counts.pending += 1 + end + if pr.reviewDecision == "APPROVED" then + counts.approved += 1 + elseif pr.reviewDecision == "CHANGES_REQUESTED" then + counts.changes_requested += 1 + end + if pr.isDraft then + counts.drafts += 1 + end + end + return counts +end + +-- fetch_status last: watchers triggering on it see prs/counts already updated. +local function publish(prs, status, errorMessage, overflow, rulesCount, updateTimestamp) + noctalia.state.set("prs", prs) + noctalia.state.set("counts", computeCounts(prs)) + noctalia.state.set("overflow_count", overflow) + noctalia.state.set("rules_count", rulesCount) + if updateTimestamp then + noctalia.state.set("last_updated_text", noctalia.formatTime("%H:%M")) + end + noctalia.state.set("error_message", errorMessage) + noctalia.state.set("fetch_status", status) +end + +local function classifyResult(result) + if result.timedOut then + return nil, noctalia.tr("error.timeout") + end + if result.exitCode ~= 0 then + local message = noctalia.string.trim(result.stderr or ""):match("^[^\r\n]+") + return nil, message or `gh exited with code {result.exitCode}` + end + if result.stdoutTruncated then + return nil, noctalia.tr("error.truncated") + end + local decoded = noctalia.json.decode(result.stdout) + if type(decoded) ~= "table" or type(decoded.items) ~= "table" then + return nil, noctalia.tr("error.parse") + end + return decoded, nil +end + +local function mergeAndPublish(ruleResults, ruleErrors, rulesCount, excludedRepositories) + local prs = {} + local seen = {} + local overflow = 0 + for _, decoded in ipairs(ruleResults) do + for _, pr in ipairs(decoded.items) do + if not seen[pr.url] and not repositoryExcluded(pr.repo, excludedRepositories) then + seen[pr.url] = true + table.insert(prs, pr) + end + end + local count = tonumber(decoded.count) or 0 + overflow += math.max(0, count - RESULTS_PER_RULE) + end + -- ISO-8601 UTC timestamps sort lexicographically; newest first. + table.sort(prs, function(a, b) + return a.updatedAt > b.updatedAt + end) + + if #ruleErrors >= rulesCount then + -- Every rule failed: keep the previous (still filtered) list visible. + local previous = filterExcluded(noctalia.state.get("prs"), excludedRepositories) + publish(previous, "error", ruleErrors[1], noctalia.state.get("overflow_count"), rulesCount, false) + elseif #ruleErrors > 0 then + local warning = noctalia.tr("panel.partial_warning", { + failed = #ruleErrors, + total = rulesCount, + message = ruleErrors[1], + }) + publish(prs, "ok", warning, overflow, rulesCount, true) + else + publish(prs, "ok", "", overflow, rulesCount, true) + end +end + +local function startFetch() + if fetching then + return + end + + local rules = readRules() + local excludedRepositories = readExcludedRepositories() + if #rules == 0 then + publish({}, "ok", "", 0, 0, true) + return + end + + if not noctalia.commandExists("gh") then + local previous = filterExcluded(noctalia.state.get("prs"), excludedRepositories) + publish(previous, "error", noctalia.tr("error.gh_missing"), 0, #rules, false) + return + end + + fetching = true + fetchGeneration += 1 + local thisGeneration = fetchGeneration + + -- Flip to loading but keep the stale list so the UI never flashes empty. + noctalia.state.set("error_message", "") + noctalia.state.set("fetch_status", "loading") + + local pending = #rules + local ruleResults = {} + local ruleErrors = {} + + local function ruleDone() + pending -= 1 + if pending == 0 then + fetching = false + mergeAndPublish(ruleResults, ruleErrors, #rules, excludedRepositories) + end + end + + for _, rule in ipairs(rules) do + local started = noctalia.runAsync(buildCommand(rule), function(result) + if thisGeneration ~= fetchGeneration then + return -- config changed mid-flight; a newer fetch owns the state + end + local decoded, err = classifyResult(result) + if decoded then + table.insert(ruleResults, decoded) + else + table.insert(ruleErrors, `{rule}: {err}`) + noctalia.log(`github-prs: rule "{rule}" failed: {err}`) + end + ruleDone() + end, GH_TIMEOUT_MS) + + if not started then + table.insert(ruleErrors, `{rule}: could not start gh`) + ruleDone() + end + end +end + +local function restartFetch() + -- Existing gh processes cannot be cancelled, so invalidate their callbacks + -- before starting the replacement fetch. + if fetching then + fetching = false + fetchGeneration += 1 + end + startFetch() +end + +noctalia.setUpdateInterval(readIntervalMs()) + +if noctalia.state.get("fetch_status") == nil then + noctalia.state.set("last_updated_text", "") + publish({}, "loading", "", 0, #readRules(), false) +end + +noctalia.state.watch("refresh_requested", function(value) + local nonce = tonumber(value) or 0 + if nonce ~= lastRefreshNonce then + lastRefreshNonce = nonce + restartFetch() + end +end) + +startFetch() + +function update() + startFetch() +end + +-- Defining this keeps the service alive across settings edits (no VM restart); +-- everything config-derived must be re-read here. +function onConfigChanged() + noctalia.setUpdateInterval(readIntervalMs()) + restartFetch() +end + +function onIpc(event, _payload) + if event == "refresh" then + restartFetch() + elseif event == "dump" then + local summary = { + status = noctalia.state.get("fetch_status"), + count = #noctalia.state.get("prs"), + error = noctalia.state.get("error_message"), + updated = noctalia.state.get("last_updated_text"), + } + noctalia.log("github-prs: " .. (noctalia.json.encode(summary) or "?")) + end +end diff --git a/github-prs/panel.luau b/github-prs/panel.luau new file mode 100644 index 00000000..2300eb3c --- /dev/null +++ b/github-prs/panel.luau @@ -0,0 +1,277 @@ +--!nonstrict +-- [[panel]] entry: pull requests grouped by repository, rendered from the +-- shared state the fetch service publishes. Each pull request uses two lines so +-- long titles cannot collide with the author or status badges; clicking the +-- title opens the PR in the browser. The header carries the last-refresh time, +-- an icon-only refresh action, and a close button. +-- +local MAX_ROWS = 80 +local CENTERED_LAYOUT = { flexGrow = 1, align = "center", justify = "center", gap = 8 } + +local CI_APPEARANCE = { + SUCCESS = { glyph = "circle-check", color = "primary" }, + FAILURE = { glyph = "circle-x", color = "error" }, + ERROR = { glyph = "alert-circle", color = "error" }, + PENDING = { glyph = "clock", color = "tertiary" }, + EXPECTED = { glyph = "clock", color = "tertiary" }, +} +local CI_NONE = { glyph = "circle-dashed", color = "on_surface_variant" } + +local REVIEW_BADGE = { + APPROVED = { key = "panel.review.approved", color = "primary" }, + CHANGES_REQUESTED = { key = "panel.review.changes_requested", color = "error" }, + REVIEW_REQUIRED = { key = "panel.review.review_required", color = "tertiary" }, +} + +local prs = {} +local fetchStatus = "loading" +local errorMessage = "" +local lastUpdatedText = "" +local rulesCount = nil +local overflowCount = 0 +local isOpen = false +local collapsedRepos = {} + +local render + +local tr = noctalia.tr + +local function syncFromState() + prs = noctalia.state.get("prs") + fetchStatus = noctalia.state.get("fetch_status") + errorMessage = noctalia.state.get("error_message") + lastUpdatedText = noctalia.state.get("last_updated_text") + rulesCount = noctalia.state.get("rules_count") + overflowCount = noctalia.state.get("overflow_count") + + local activeRepos = {} + for _, pr in ipairs(prs) do + activeRepos[pr.repo] = true + end + for repo, _ in pairs(collapsedRepos) do + if not activeRepos[repo] then + collapsedRepos[repo] = nil + end + end +end + +local function badge(text, color, fillAlpha) + return ui.row({ + fill = `{color}/{fillAlpha}`, + border = color .. "/0.24", + borderWidth = 1, + radius = 8, + paddingH = 7, + paddingV = 2, + align = "center", + }, { + ui.label({ text = text, fontSize = 11, fontWeight = "medium", color = color }), + }) +end + +local function prRow(pr) + local ci = CI_APPEARANCE[pr.ci] or CI_NONE + + local statusChildren = { + ui.glyph({ name = "user", size = 13, color = "on_surface_variant" }), + ui.label({ + text = pr.author, + fontSize = 11, + color = "on_surface_variant", + flexGrow = 1, + maxLines = 1, + }), + ui.glyph({ name = ci.glyph, size = 16, color = ci.color }), + } + if pr.isDraft then + table.insert(statusChildren, badge(tr("panel.draft"), "on_surface_variant", "0.15")) + end + local review = REVIEW_BADGE[pr.reviewDecision] + if review then + table.insert(statusChildren, badge(tr(review.key), review.color, "0.2")) + end + + return ui.column({ key = pr.url, gap = 4, paddingH = 10, paddingV = 7, align = "stretch" }, { + ui.row({ gap = 9, align = "center" }, { + ui.glyph({ name = "git-pull-request", size = 15, color = "primary" }), + ui.button({ + key = `open:{pr.url}`, + text = `#{pr.number} {pr.title}`, + variant = "ghost", + contentAlign = "start", + flexGrow = 1, + onClick = function() + local url = "'" .. pr.url:gsub("'", "'\\''") .. "'" + noctalia.runAsync("xdg-open " .. url) + end, + }), + }), + ui.row({ gap = 7, align = "center", paddingH = 24 }, statusChildren), + }) +end + +-- Group by repo and order groups by their newest PR activity. prs is globally +-- sorted by updatedAt desc, so appending in order also keeps each group recent. +local function groupedRows() + local byRepo = {} + local repoNames = {} + for _, pr in ipairs(prs) do + if byRepo[pr.repo] == nil then + byRepo[pr.repo] = {} + table.insert(repoNames, pr.repo) + end + table.insert(byRepo[pr.repo], pr) + end + + local rows = {} + local shown = 0 + local hiddenByCollapse = 0 + for _, repo in ipairs(repoNames) do + if shown >= MAX_ROWS then + break + end + local collapsed = collapsedRepos[repo] + -- Capture this iteration's repository for the click callback. + local repoName = repo + local groupChildren = { + ui.row({ + key = `repo:{repo}`, + gap = 8, + align = "center", + paddingH = 11, + paddingV = 8, + fill = "surface_variant/0.24", + onClick = function() + collapsedRepos[repoName] = not collapsedRepos[repoName] + render() + end, + }, { + ui.glyph({ name = collapsed and "chevron-right" or "chevron-down", size = 15, color = "primary" }), + ui.glyph({ name = "books", size = 16, color = "primary" }), + ui.label({ text = repo, fontSize = 13, fontWeight = "bold", color = "primary", flexGrow = 1 }), + badge(tostring(#byRepo[repo]), "primary", "0.14"), + }), + } + if collapsed then + hiddenByCollapse += #byRepo[repo] + else + for _, pr in ipairs(byRepo[repo]) do + if shown >= MAX_ROWS then + break + end + table.insert(groupChildren, ui.separator({ thickness = 1, color = "outline/0.28", spacing = 0 })) + table.insert(groupChildren, prRow(pr)) + shown += 1 + end + end + table.insert(rows, ui.column({ + key = `repo-group:{repo}`, + fill = "surface_variant/0.10", + border = "outline/0.40", + borderWidth = 1, + radius = 11, + align = "stretch", + }, groupChildren)) + end + + local omitted = #prs - shown - hiddenByCollapse + if omitted > 0 then + table.insert(rows, ui.label({ + key = "more_rows", + text = tr("panel.more_rows", { count = omitted }), + fontSize = 11, + color = "on_surface_variant", + })) + end + if overflowCount > 0 then + table.insert(rows, ui.label({ + key = "truncated_note", + text = tr("panel.truncated_note", { count = overflowCount }), + fontSize = 11, + color = "on_surface_variant", + })) + end + return rows +end + +local function body() + if #prs == 0 then + if fetchStatus == "loading" then + return ui.column(CENTERED_LAYOUT, { + ui.label({ text = tr("panel.loading"), color = "on_surface_variant" }), + }) + end + if fetchStatus == "error" then + return ui.column(CENTERED_LAYOUT, { + ui.label({ text = tr("panel.error_title"), fontWeight = "bold", color = "error" }), + ui.label({ text = errorMessage, color = "on_surface_variant", maxLines = 4, textAlign = "center" }), + ui.button({ + text = tr("panel.retry"), + variant = "primary", + onClick = function() + noctalia.state.set("refresh_requested", (noctalia.state.get("refresh_requested") or 0) + 1) + end, + }), + }) + end + local emptyKey = rulesCount == 0 and "panel.empty_no_rules" or "panel.empty" + return ui.column(CENTERED_LAYOUT, { + ui.label({ text = tr(emptyKey), color = "on_surface_variant", textAlign = "center" }), + }) + end + return ui.scroll({ flexGrow = 1, gap = 10, align = "stretch" }, groupedRows()) +end + +render = function() + if not isOpen then + return + end + + local children = { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.row({ gap = 8, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "brand-github", size = 20, color = "primary" }), + ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", color = "on_surface" }), + badge(tostring(#prs), "primary", "0.14"), + }), + ui.label({ + text = lastUpdatedText ~= "" and tr("panel.updated_at", { time = lastUpdatedText }) or "", + fontSize = 11, + color = "on_surface_variant", + }), + ui.button({ + glyph = "refresh", + variant = "ghost", + enabled = fetchStatus ~= "loading", + onClick = function() + noctalia.state.set("refresh_requested", (noctalia.state.get("refresh_requested") or 0) + 1) + end, + }), + ui.button({ glyph = "close", onClick = panel.close }), + }), + } + + -- Partial rule failure: data is fresh but incomplete — warn without blocking. + if errorMessage ~= "" and fetchStatus ~= "error" then + table.insert(children, ui.label({ text = errorMessage, fontSize = 11, color = "error", maxLines = 2 })) + end + + table.insert(children, body()) + + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children)) +end + +noctalia.state.watch("fetch_status", function() + syncFromState() + render() +end) + +function onOpen(_context) + syncFromState() + isOpen = true + render() +end + +function onClose() + isOpen = false +end diff --git a/github-prs/plugin.toml b/github-prs/plugin.toml new file mode 100644 index 00000000..0e9a34ed --- /dev/null +++ b/github-prs/plugin.toml @@ -0,0 +1,73 @@ +# GitHub Pull Requests — pull requests in your bar, fetched via the gh CLI. +# Three entries: a headless fetch service (owns all gh calls, publishes shared +# state), a bar widget (count + status tint, click toggles the panel), and a +# panel (PRs grouped by repo with CI status and review badges). + +id = "raycursive/github-prs" +name = "GitHub Pull Requests" +version = "0.3.0" +plugin_api = 9 +author = "raycursive" +license = "MIT" +dependencies = ["gh", "xdg-open"] +tags = ["bar", "development", "panel", "productivity", "service"] +icon = "git-pull-request" +description = "Track matching GitHub pull requests from the bar, including CI and review status, using the gh CLI." + +# Each rule is a GitHub search query fragment; the service prefixes it with +# "is:pr is:open" (and "archived:false" unless the rule mentions archived:). +[[setting]] +key = "rules" +type = "string_list" +label_key = "settings.rules.label" +description_key = "settings.rules.description" +default = ["author:@me"] + +[[setting]] +key = "excluded_repositories" +type = "string_list" +label_key = "settings.excluded_repositories.label" +description_key = "settings.excluded_repositories.description" +default = [] + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 180 +min = 30 +max = 3600 + +# Headless service: runs one gh call per rule, merges + dedupes, publishes to +# the plugin's shared state for the widget and panel. +[[service]] +id = "fetch" +entry = "fetch.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + default = "git-pull-request" + + [[widget.setting]] + key = "hide_when_zero" + type = "bool" + label_key = "settings.hide_when_zero.label" + description_key = "settings.hide_when_zero.description" + default = false + +# Open with the bar widget click, or: noctalia msg panel-toggle raycursive/github-prs:panel +[[panel]] +id = "panel" +entry = "panel.luau" +width = 660 +height = 640 +placement = "attached" +position = "auto" +open_near_click = true diff --git a/github-prs/thumbnail.webp b/github-prs/thumbnail.webp new file mode 100644 index 00000000..c5ea0b51 Binary files /dev/null and b/github-prs/thumbnail.webp differ diff --git a/github-prs/translations/en.json b/github-prs/translations/en.json new file mode 100644 index 00000000..a0afb1b3 --- /dev/null +++ b/github-prs/translations/en.json @@ -0,0 +1,47 @@ +{ + "title": "GitHub Pull Requests", + "settings": { + "rules": { + "label": "Search rules", + "description": "One GitHub search query fragment per entry; results are merged. Each is prefixed with 'is:pr is:open' (and 'archived:false' unless you set archived: yourself). Examples: author:@me · review-requested:@me org:acme · assignee:@me -repo:acme/legacy draft:false" + }, + "excluded_repositories": { + "label": "Excluded repositories", + "description": "Repository names to hide after fetching. * is supported; names without an owner match every organization. Examples: legacy-* · acme/private-*" + }, + "refresh_interval": { + "label": "Refresh interval (seconds)", + "description": "How often pull requests are re-fetched." + }, + "glyph": { + "label": "Bar glyph" + }, + "hide_when_zero": { + "label": "Hide when empty", + "description": "Hide the bar widget when there are no open pull requests." + } + }, + "panel": { + "updated_at": "Updated {time}", + "loading": "Loading pull requests…", + "empty": "No open pull requests. All clear!", + "empty_no_rules": "No search rules configured. Add rules in Settings → Plugins → GitHub Pull Requests.", + "error_title": "Couldn't fetch pull requests", + "retry": "Retry", + "partial_warning": "{failed} of {total} rules failed: {message}", + "truncated_note": "…{count} more matches not shown (per-rule cap is 50)", + "more_rows": "…and {count} more", + "draft": "Draft", + "review": { + "approved": "Approved", + "changes_requested": "Changes requested", + "review_required": "Review needed" + } + }, + "error": { + "gh_missing": "GitHub CLI (gh) not found in PATH. Install it, then run: gh auth login", + "timeout": "gh timed out", + "truncated": "gh output was truncated", + "parse": "Could not parse gh output" + } +}