diff --git a/README.md b/README.md index 7804d773..1caef3a9 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,16 @@ require"octo".setup({ auto_show_threads = true, -- automatically show comment threads on cursor move focus = "right", -- focus right buffer on diff open }, + runs = { + icons = { + pending = "🕖", + in_progress = "🔄", + failed = "❌", + succeeded = "", + skipped = "⏩", + cancelled = "✖", + }, + }, pull_requests = { order_by = { -- criteria to sort the results of `Octo pr list` field = "CREATED_AT", -- either COMMENTS, CREATED_AT or UPDATED_AT (https://docs.github.com/en/graphql/reference/enums#issueorderfield) @@ -174,6 +184,12 @@ require"octo".setup({ }, mappings_disable_default = false, -- disable default mappings if true, but will still adapt user mappings mappings = { + runs = { + expand_step = { lhs = "o", desc = "expand workflow step" }, + open_in_browser = { lhs = "", desc = "open workflow run in browser" }, + refresh = { lhs = "", desc = "refresh workflow" }, + copy_url = { lhs = "", desc = "copy url to system clipboard" }, + }, issue = { close_issue = { lhs = "ic", desc = "close issue" }, reopen_issue = { lhs = "io", desc = "reopen issue" }, @@ -389,6 +405,7 @@ If no command is passed, the argument to `Octo` is treated as a URL from where a | | close | Close the review window and return to the PR | | actions | | Lists all available Octo actions | | search | | Search GitHub for issues and PRs matching the [query](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests) | +| run | list | List workflow runs | | notification | list | Shows current unread notifications | 0. `[repo]`: If repo is not provided, it will be derived from `/.git/config`. diff --git a/lua/octo/commands.lua b/lua/octo/commands.lua index 6274091b..1bdbc14f 100644 --- a/lua/octo/commands.lua +++ b/lua/octo/commands.lua @@ -74,6 +74,16 @@ function M.setup() -- supported commands M.commands = { + run = { + list = function() + local function co_wrapper() + require("octo.workflow_runs").list() + end + + local co = coroutine.create(co_wrapper) + coroutine.resume(co) + end, + }, actions = function() M.actions() end, diff --git a/lua/octo/config.lua b/lua/octo/config.lua index 8be6b8dc..8993645b 100644 --- a/lua/octo/config.lua +++ b/lua/octo/config.lua @@ -1,7 +1,7 @@ local vim = vim local M = {} ----@alias OctoMappingsWindow "issue" | "pull_request" | "review_thread" | "submit_win" | "review_diff" | "file_panel" | "repo" | "notification" +---@alias OctoMappingsWindow "issue" | "pull_request" | "review_thread" | "submit_win" | "review_diff" | "file_panel" | "repo" | "notification" | "runs" ---@alias OctoMappingsList { [string]: table} ---@alias OctoPickers "telescope" | "fzf-lua" | "snacks" ---@alias OctoSplit "right" | "left" @@ -43,6 +43,17 @@ local M = {} ---@class OctoConfigDiscussions ---@field order_by OctoConfigOrderBy +---@class OctoConfigWorkflowIcons +---@field pending string +---@field skipped string +---@field in_progress string +---@field failed string +---@field succeeded string +---@field cancelled string + +---@class OctoConfigRuns +---@field icons OctoConfigWorkflowIcons + ---@class OctoConfigNotifications ---@field current_repo_only boolean @@ -88,6 +99,7 @@ local M = {} ---@field ui OctoConfigUi ---@field issues OctoConfigIssues ---@field reviews OctoConfigReviews +---@field runs OctoConfigRuns ---@field pull_requests OctoConfigPR ---@field file_panel OctoConfigFilePanel ---@field colors OctoConfigColors @@ -161,6 +173,16 @@ function M.get_default_values() auto_show_threads = true, focus = "right", }, + runs = { + icons = { + pending = "🕖", + in_progress = "🔄", + failed = "❌", + succeeded = "", + skipped = "⏩", + cancelled = "✖", + }, + }, pull_requests = { order_by = { field = "CREATED_AT", @@ -188,6 +210,12 @@ function M.get_default_values() }, mappings_disable_default = false, mappings = { + runs = { + expand_step = { lhs = "o", desc = "expand workflow step" }, + open_in_browser = { lhs = "", desc = "open workflow run in browser" }, + refresh = { lhs = "", desc = "refresh workflow" }, + copy_url = { lhs = "", desc = "copy url to system clipboard" }, + }, issue = { close_issue = { lhs = "ic", desc = "close issue" }, reopen_issue = { lhs = "io", desc = "reopen issue" }, diff --git a/lua/octo/constants.lua b/lua/octo/constants.lua index 87cb9c3e..241bbb5a 100644 --- a/lua/octo/constants.lua +++ b/lua/octo/constants.lua @@ -17,6 +17,8 @@ M.OCTO_EMPTY_MSG_VT_NS = vim.api.nvim_create_namespace "octo_empty_msg_vt" M.OCTO_THREAD_HEADER_VT_NS = vim.api.nvim_create_namespace "octo_thread_header_vt" M.OCTO_EVENT_VT_NS = vim.api.nvim_create_namespace "octo_event_vt" +M.OCTO_WORKFLOW_NS = vim.api.nvim_create_namespace "octo_workflow" + M.NO_BODY_MSG = "No description provided." M.LONG_ISSUE_PATTERN = "([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(%d+)" diff --git a/lua/octo/navigation.lua b/lua/octo/navigation.lua index 1c7cd124..fd8fa131 100644 --- a/lua/octo/navigation.lua +++ b/lua/octo/navigation.lua @@ -62,6 +62,8 @@ function M.open_in_browser(kind, repo, number) cmd = string.format("gh gist view --web %s", number) elseif kind == "project" then cmd = string.format("gh project view --owner %s --web %s", repo, number) + elseif kind == "workflow_run" then + cmd = string.format("gh run view %s --web", number) end end pcall(vim.cmd, "silent !" .. cmd) diff --git a/lua/octo/pickers/fzf-lua/provider.lua b/lua/octo/pickers/fzf-lua/provider.lua index 011b20a5..45122107 100644 --- a/lua/octo/pickers/fzf-lua/provider.lua +++ b/lua/octo/pickers/fzf-lua/provider.lua @@ -29,6 +29,7 @@ M.picker = { users = require "octo.pickers.fzf-lua.pickers.users", notifications = M.not_implemented, milestones = M.not_implemented, + workflow_runs = M.not_implemented, } return M diff --git a/lua/octo/pickers/snacks/provider.lua b/lua/octo/pickers/snacks/provider.lua index e000e285..b7aaceab 100644 --- a/lua/octo/pickers/snacks/provider.lua +++ b/lua/octo/pickers/snacks/provider.lua @@ -332,6 +332,7 @@ M.picker = { project_columns_v2 = M.not_implemented, prs = M.pull_requests, repos = M.not_implemented, + workflow_runs = M.not_implemented, review_commits = M.not_implemented, search = M.not_implemented, users = M.not_implemented, diff --git a/lua/octo/pickers/telescope/entry_maker.lua b/lua/octo/pickers/telescope/entry_maker.lua index 4292084d..f21ff193 100644 --- a/lua/octo/pickers/telescope/entry_maker.lua +++ b/lua/octo/pickers/telescope/entry_maker.lua @@ -198,6 +198,16 @@ function M.gen_from_git_changed_files() end end +function M.gen_from_workflow_run() + return function(workflow_run) + return { + display = workflow_run.display, + value = workflow_run, + ordinal = workflow_run.display, + } + end +end + function M.gen_from_review_thread(linenr_length) local make_display = function(entry) if not entry then diff --git a/lua/octo/pickers/telescope/previewers.lua b/lua/octo/pickers/telescope/previewers.lua index 1e9d56aa..ed4c5171 100644 --- a/lua/octo/pickers/telescope/previewers.lua +++ b/lua/octo/pickers/telescope/previewers.lua @@ -7,6 +7,7 @@ local previewers = require "telescope.previewers" local pv_utils = require "telescope.previewers.utils" local ts_utils = require "telescope.utils" local defaulter = ts_utils.make_default_callable +local workflow_runs_previewer = require("octo.workflow_runs").previewer local vim = vim @@ -224,7 +225,14 @@ local issue_template = defaulter(function(opts) } end, {}) +local workflow_runs = defaulter(function(opts) + return previewers.new_buffer_previewer { + define_preview = workflow_runs_previewer, + } +end, {}) + return { + workflow_runs = workflow_runs, discussion = discussion, issue = issue, gist = gist, diff --git a/lua/octo/pickers/telescope/provider.lua b/lua/octo/pickers/telescope/provider.lua index 49390abc..68446f11 100644 --- a/lua/octo/pickers/telescope/provider.lua +++ b/lua/octo/pickers/telescope/provider.lua @@ -638,6 +638,30 @@ function M.pending_threads(threads) :find() end +function M.workflow_runs(workflow_runs, title, on_select_cb) + pickers + .new({}, { + prompt_title = title or false, + results_title = false, + preview_title = false, + finder = finders.new_table { + results = workflow_runs, + entry_maker = entry_maker.gen_from_workflow_run(), + }, + sorter = conf.generic_sorter {}, + previewer = previewers.workflow_runs.new {}, + attach_mappings = function() + actions.select_default:replace(function(prompt_bufnr) + local selection = action_state.get_selected_entry(prompt_bufnr) + actions.close(prompt_bufnr) + on_select_cb(selection.value) + end) + return true + end, + }) + :find() +end + --- -- PROJECTS --- @@ -1472,6 +1496,7 @@ M.picker = { notifications = M.notifications, pending_threads = M.pending_threads, project_cards = M.select_project_card, + workflow_runs = M.workflow_runs, project_cards_v2 = M.not_implemented, project_columns = M.select_target_project_column, project_columns_v2 = M.not_implemented, diff --git a/lua/octo/pickers/vim-clap/provider.lua b/lua/octo/pickers/vim-clap/provider.lua index 3f2783dc..426220af 100644 --- a/lua/octo/pickers/vim-clap/provider.lua +++ b/lua/octo/pickers/vim-clap/provider.lua @@ -29,6 +29,7 @@ M.picker = { search = M.not_implemented, users = M.not_implemented, milestones = M.not_implemented, + workflow_runs = M.not_implemented, } return M diff --git a/lua/octo/workflow_runs.lua b/lua/octo/workflow_runs.lua new file mode 100644 index 00000000..51264153 --- /dev/null +++ b/lua/octo/workflow_runs.lua @@ -0,0 +1,718 @@ +local namespace = require("octo.constants").OCTO_WORKFLOW_NS +local mappings = require("octo.config").values.mappings.runs +local icons = require("octo.config").values.runs.icons +local navigation = require "octo.navigation" +local utils = require "octo.utils" +local gh = require "octo.gh" + +---@alias LineType "job" | "step" | "step_log" | nil + +---@class WorkflowRun +---@field conclusion string +---@field createdAt string +---@field databaseId string +---@field displayTitle string +---@field event string +---@field headBranch string +---@field headSha string +---@field name string +---@field number number +---@field startedAt string +---@field status string +---@field updatedAt string +---@field url string +---@field workflowDatabaseId string +---@field workflowName string +---@field jobs WorkflowJob[] + +---@class WorkflowJob +---@field completedAt string +---@field conclusion string +---@field databaseId number +---@field name string +---@field startedAt string +---@field status string +---@field steps WorkflowStep[] +---@field url string + +---@class WorkflowStep +---@field conclusion string +---@field name string +---@field number number +---@field status string + +---@class LineDef +---@field value string +---@field id string | nil +---@field highlight string | nil +---@field node_ref WorkflowNode | nil + +---@class Handler +---@field tree table +---@field buf_name string +---@field buf integer +---@field filetype string +---@field current_wf WorkflowRun +---@field wf_cache table +---@field refresh function +---@field refetch function + +---@class WorkflowNode +---@field id string +---@field display string +---@field type LineType +---@field job_id string +---@field indent number +---@field expanded boolean +---@field number number | nil +---@field highlight string | nil +---@field preIcon string +---@field icon string +---@field status string +---@field conclusion string +---@field children table + +local M = { + buf = nil, + buf_name = "", + filetype = "", + tree = {}, + current_wf = nil, + wf_cache = {}, +} + +---@return string | nil +local function get_job_highlight(status, conclusion) + if status == "queued" then + return "Question" + elseif status == "in_progress" then + return "Directory" + elseif conclusion == "success" then + return "Character" + elseif conclusion == "failure" then + return "ErrorMsg" + elseif conclusion == "skipped" then + return "NonText" + elseif conclusion == "cancelled" then + return "NonText" + end +end + +---@return string | nil +local function get_step_highlight(status, conclusion) + if status == "pending" then + return "Question" + elseif status == "in_progress" then + return "Directory" + elseif conclusion == "success" then + return "Character" + elseif conclusion == "failure" then + return "ErrorMsg" + elseif conclusion == "skipped" then + return "NonText" + elseif conclusion == "cancelled" then + return "NonText" + end +end + +---@param data WorkflowRun +---@return WorkflowNode +local function generate_workflow_tree(data) + local root = { + id = "Jobs", + display = "Jobs", + type = "root", + job_id = "", + indent = 0, + expanded = true, + highlight = nil, + preIcon = "", + icon = "📂", + children = {}, + } + + for _, job in ipairs(data.jobs or {}) do + local jobNode = { + id = job.name, + job_id = job.name, + display = job.name, + type = "job", + indent = 2, + expanded = true, + highlight = get_job_highlight(job.status, job.conclusion), + status = job.status, + conclusion = job.conclusion, + preIcon = "", + icon = "🛠️", + children = {}, + } + + for _, step in ipairs(job.steps or {}) do + ---@type WorkflowNode + local stepNode = { + id = step.name, + job_id = jobNode.id, + display = step.name, + status = step.status, + number = step.number, + conclusion = step.conclusion, + type = "step", + indent = 4, + expanded = false, + highlight = get_step_highlight(step.status, step.conclusion), + preIcon = "", + icon = "", + children = {}, + } + table.insert(jobNode.children, stepNode) + end + + table.insert(root.children, jobNode) + end + + return root +end + +local function extract_after_timestamp(logLine) + if logLine == nil then + return "" + end + local result = logLine:match "%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d%.%d+Z%s*(.*)" + return result or "" +end + +---Traverses a tree from the given node, giving a callback for every item +---@param tree WorkflowNode | nil +---@param cb function +M.traverse = function(tree, cb) + if not tree then + tree = M.tree + end + --HACK: handle no tree set + if not tree.id then + return + end + + cb(tree) + for _, node in ipairs(tree.children or {}) do + M.traverse(node, cb) + end +end + +local function collapse_groups(lines) + local collapsed = {} + local current_group = nil + + for _, line in ipairs(lines) do + if extract_after_timestamp(line):find "##%[group%]" then + current_group = { line } + elseif extract_after_timestamp(line):find "##%[endgroup%]" then + if current_group then + table.insert(collapsed, table.concat(current_group, "\n")) + current_group = nil + else + error("Mismatched ##[endgroup] found: " .. line) + end + elseif current_group then + table.insert(current_group, line) + else + table.insert(collapsed, line) + end + end + + if current_group then + error "Unclosed group found." + end + + return collapsed +end + +local function create_log_child(value, indent) + return { + display = extract_after_timestamp(value) + :gsub("##%[group%]", "> ") + :gsub("##%[endgroup%]", "") + :gsub("%[command%]", "") + :gsub("##%[warning%]", "Warning: ") + :gsub("##%[notice%]", "Notice: ") + --strip ansi color codes + :gsub("\x1b%[[%d;]*m", ""), + id = value, + expanded = false, + indent = indent + 2, + type = "step_log", + highlight = value:find "%[command%]" ~= nil and "PreProc" or "Question", + icon = "", + preIcon = "", + children = {}, + } +end + +local function get_temp_filepath(length) + length = length or 7 + local name = "" + while length > #name do + name = name .. string.char(math.random(65, 65 + 25)):lower() + end + return vim.fs.joinpath(vim.fs.normalize(vim.fn.stdpath "cache"), name) +end + +-- Accepts zip contents and writes and then unzips them +---@param stdout string - The zip content to write +local function write_zipped_file(stdout) + local zip_location = get_temp_filepath() + local file = io.open(zip_location, "wb") + if not file then + utils.error "Failed to create temporary file" + return + end + + file:write(stdout) + file:close() + + return zip_location, + function() + local unlink_success, unlink_error = pcall(function() + vim.loop.fs_unlink(zip_location) + end) + + if not unlink_success then + utils.error("Error deleting logs archive: " .. unlink_error) + end + end + --TODO: return handler for deleting file +end + +local function get_logs(id) + utils.info "Fetching workflow logs (this may take a while) ..." + local reponame = utils.get_remote_name() + local cmd = { + "gh", + "api", + string.format("repos/%s/actions/runs/%s/logs", reponame, id, 0), + } + local out = vim.system(cmd):wait() + + if out.code ~= 0 then + utils.error("Failed to fetch logs: " .. (out.stderr or "Unknown error")) + return + end + + local zip_location, cleanup = write_zipped_file(out.stdout) + + ---@param node WorkflowNode + M.traverse(M.tree, function(node) + if node.type ~= "step" or node.conclusion == "skipped" then + return + end + + local sanitized_name = node.id:gsub("/", ""):gsub(":", ""):gsub(">", "") + --Make more than 3 consecutive dots at the end of line into ... + local sanitized_job_id = node.job_id:gsub("/", ""):gsub(":", ""):gsub("%.+$", "*/") + local file_name = string.format("%s_%s.txt", node.number, sanitized_name) + local path = sanitized_job_id .. file_name + local res = vim + .system({ + "unzip", + "-p", + zip_location, + path, + }) + :wait() + + if res.code ~= 0 then + utils.error("Failed to extract logs for " .. node.id) + end + + local lines = vim.tbl_filter(function(i) + return i ~= nil and i ~= "" + end, vim.split(res.stdout, "\n")) + + node.children = {} + + for _, collapsed in ipairs(collapse_groups(lines)) do + local groupedLines = vim.fn.split(collapsed, "\n") + local log_child = create_log_child(groupedLines[1], node.indent) + if #groupedLines > 1 then + local sub = {} + for i, value in ipairs(groupedLines) do + if i ~= 1 then + table.insert(sub, create_log_child(value, log_child.indent)) + end + end + log_child.children = sub + end + + table.insert(node.children, log_child) + end + end) + if cleanup then + cleanup() + end +end + +local keymaps = { + ---@param api Handler + [mappings.refresh.lhs] = function(api) + utils.info "refreshing..." + api.refetch() + end, + [mappings.open_in_browser.lhs] = function(api) + local id = api.current_wf.databaseId + navigation.open_in_browser("workflow_run", nil, id) + end, + [mappings.copy_url.lhs] = function(api) + local url = api.current_wf.url + vim.fn.setreg("+", url, "c") + utils.info("Copied URL '" .. url .. "' to the system clipboard (+ register)") + end, +} + +local function find_parent(tree, target_id) + if not tree or not tree.children then + return nil + end + + for _, child in pairs(tree.children) do + if child.id == target_id then + return tree + end + local parent = find_parent(child, target_id) + if parent then + return parent + end + end + + return nil +end + +local tree_keymaps = { + ---@param node WorkflowNode + [mappings.expand_step.lhs] = function(node) + if node.type == "step_log" and not next(node.children) then + local parent = find_parent(M.tree, node.id) + if parent then + parent.expanded = false + M.refresh() + return + end + end + + if node.expanded == false then + node.expanded = true + if node.type == "step" then + -- only refresh logs aggressively if step is in_progress + if node.conclusion == "in_progress" then + utils.error "Cant view logs of running workflow..." + return + end + if not next(node.children) then + get_logs(M.current_wf.databaseId) + end + end + else + node.expanded = false + end + M.refresh() + end, +} + +local fields = + "conclusion,createdAt,databaseId,displayTitle,event,headBranch,headSha,jobs,name,number,startedAt,status,updatedAt,url,workflowDatabaseId,workflowName" + +local function get_job_status(status, conclusion) + if status == "queued" then + return icons.skipped + elseif status == "in_progress" then + return icons.in_progress + elseif conclusion == "success" then + return "" + elseif conclusion == "failure" then + return icons.failed + elseif conclusion == "skipped" then + return icons.skipped + elseif conclusion == "cancelled" then + return icons.cancelled + else + return "❓" + end +end + +local function get_step_status(status, conclusion) + if status == "pending" then + return icons.pending + elseif status == "in_progress" then + return icons.in_progress + elseif conclusion == "success" then + return "" + elseif conclusion == "failure" then + return icons.failed + elseif conclusion == "skipped" then + return icons.skipped + elseif conclusion == "cancelled" then + return icons.cancelled + else + return "❓" + end +end + +local function get_workflow_status(status, conclusion) + if status == "queued" then + return icons.pending + elseif status == "in_progress" then + return icons.in_progress + elseif conclusion == "success" then + return icons.succeeded + elseif conclusion == "failure" then + return icons.failed + elseif conclusion == "skipped" then + return icons.skipped + else + return "❓" + end +end + +---@type LineDef +local separator = { + value = "", + highlight = nil, + id = "", + type = "separator", +} + +local function get_workflow_header() + ---@type WorkflowRun + local details = M.current_wf + ---@type LineDef[] + local lines = {} + table.insert(lines, { + value = string.format("%s %s", details.displayTitle, get_workflow_status(details.status, details.conclusion)), + highlight = "Question", + }) + + table.insert(lines, separator) + + table.insert(lines, { value = string.format("Branch: %s", details.headBranch), highlight = "Directory" }) + table.insert(lines, { value = string.format("Event: %s", details.event), highlight = "Directory" }) + + if #details.conclusion > 0 then + table.insert( + lines, + { value = string.format("Finished: %s", utils.format_date(details.updatedAt)), highlight = "Directory" } + ) + elseif #details.startedAt > 0 then + table.insert( + lines, + { value = string.format("Started: %s", utils.format_date(details.startedAt)), highlight = "Directory" } + ) + end + + table.insert(lines, separator) + return lines +end + +local function update_job_details(id) + ---@type WorkflowRun + local job_details = {} + if M.wf_cache[id] ~= nil then + M.refresh() + return + end + + gh.run { + args = { "run", "view", id, "--json", fields }, + cb = function(output, stderr) + if stderr and not utils.is_blank(stderr) then + vim.api.nvim_err_writeln(stderr) + utils.error("Failed to get workflow run for " .. id) + elseif output then + job_details = vim.fn.json_decode(output) + M.wf_cache[id] = job_details + M.current_wf = job_details + M.tree = generate_workflow_tree(job_details) + M.refresh() + end + end, + } +end + +local function populate_preview_buffer(id, buf) + local cached = M.wf_cache[id] + if cached and vim.api.nvim_buf_is_valid(buf) then + M.current_wf = cached + M.tree = generate_workflow_tree(cached) + M.refresh() + else + update_job_details(id) + end +end + +---@param node WorkflowNode +---@return string +local function format_node(node) + local status = node.type == "step" and get_step_status(node.status, node.conclusion) + or node.type == "job" and get_job_status(node.status, node.conclusion) + or "" + + local indent = string.rep(" ", node.indent) + local preIcon = node.type ~= "step_log" and (node.expanded == true and "∨ " or "> ") or "" + local formatted = string.format("%s%s%s %s", indent, preIcon, node.display, status) + return formatted +end + +---@param node WorkflowNode +---@param list LineDef[] | nil +---@return LineDef[] +local function tree_to_string(node, list) + list = list or {} + local formatted = format_node(node) + ---@type LineDef + local lineDef = { + value = formatted, + id = node.id, + type = node.type, + highlight = node.highlight, + step_log = nil, + expanded = node.expanded or false, + node_ref = node, + } + + table.insert(list, lineDef) + if node.type ~= "step_log" then + table.insert(list, separator) + end + + if node.expanded and next(node.children) then + for _, child in ipairs(node.children) do + tree_to_string(child, list) + end + end + + return list +end + +local function print_lines() + if not vim.api.nvim_buf_is_valid(M.buf) then + return + end + vim.api.nvim_buf_clear_namespace(M.buf, namespace, 0, -1) + vim.api.nvim_buf_set_option(M.buf, "modifiable", true) + local lines = get_workflow_header() + + local stringified_tree = tree_to_string(M.tree, {}) + for _, value in ipairs(stringified_tree) do + table.insert(lines, value) + end + + local highlights = {} + + local string_lines = {} + + for index, line_def in ipairs(lines) do + table.insert(string_lines, line_def.value) + table.insert(highlights, { index = index - 1, highlight = line_def.highlight }) + end + + vim.api.nvim_buf_set_lines(M.buf, 0, -1, true, string_lines) + vim.api.nvim_buf_set_option(M.buf, "modifiable", false) + + for _, vl in ipairs(highlights) do + if vl.highlight then + vim.api.nvim_buf_add_highlight(M.buf, namespace, vl.highlight, vl.index, 0, -1) + end + end + + for binding, cb in pairs(tree_keymaps) do + vim.keymap.set("n", binding, function() + local current_line = vim.api.nvim_win_get_cursor(0)[1] + local line = lines[current_line] + if line.node_ref ~= nil then + cb(line.node_ref) + end + end, { silent = true, noremap = true, buffer = M.buf }) + end + for binding, cb in pairs(keymaps) do + vim.keymap.set("n", binding, function() + cb(M) + end, { silent = true, noremap = true, buffer = M.buf }) + end +end + +M.refresh = function() + print_lines() +end + +local workflow_limit = 100 + +local run_list_fields = "conclusion,displayTitle,event,headBranch,name,number,status,updatedAt,databaseId" + +local function get_workflow_runs_sync(co) + local lines = {} + gh.run { + args = { "run", "list", "--json", run_list_fields, "-L", workflow_limit }, + cb = function(output, stderr) + if stderr and not utils.is_blank(stderr) then + vim.api.nvim_err_writeln(stderr) + utils.error "Failed to get workflow runs" + elseif output then + local json = vim.fn.json_decode(output) + for _, value in ipairs(json) do + local status = value.status == "queued" and icons.pending + or value.status == "in_progress" and icons.in_progress + or value.conclusion == "failure" and icons.failed + or icons.succeeded + + local conclusion = value.conclusion == "skipped" and icons.skipped + or value.conclusion == "failure" and icons.failed + or "" + + local wf_run = { + status = status, + title = value.displayTitle, + display = value.displayTitle .. " " .. conclusion, + value = value.databaseId, + branch = value.headBranch, + name = value.name, + age = utils.format_date(value.updatedAt), + id = value.databaseId, + } + table.insert(lines, wf_run) + end + end + coroutine.resume(co) + end, + } + coroutine.yield() + return lines +end + +local function render(selected) + local new_buf = vim.api.nvim_create_buf(true, true) + M.buf = new_buf + vim.api.nvim_set_current_buf(new_buf) + populate_preview_buffer(selected.id, new_buf) + vim.api.nvim_buf_set_name(new_buf, "" .. selected.id) +end + +M.previewer = function(self, entry) + local id = entry.value.id + M.buf = self.state.bufnr + populate_preview_buffer(id, self.state.bufnr) +end + +M.list = function() + utils.info "Fetching workflow runs (this may take a while) ..." + local co = coroutine.running() + local wf_runs = get_workflow_runs_sync(co) + + require("octo.picker").workflow_runs(wf_runs, "Workflow runs", render) +end + +M.refetch = function() + local id = M.current_wf.databaseId + M.wf_cache[id] = nil + M.current_wf = nil + populate_preview_buffer(id, M.buf) +end + +return M