Repository: pwntester/octo.nvim (lua)

Issue:
Construct pr checks window using JSON
Using JSON will be more reliable in case of changes to the stdout

https://github.com/pwntester/octo.nvim/blob/873bcb9e46cbff61a2457ab6ce195906c9727d7d/lua/octo/commands.lua?plain=1#L1358-L1400

The required data comes from: 

`gh pr checks --json name,startedAt,description,link`

Pull request description:
<!--  Thanks for submitting a pull request! Here are some tips for you:
1. Please make sure you have read and understood the contributing guidelines: https://github.com/pwntester/octo.nvim/blob/master/CONTRIBUTING.md
2. Please make sure the PR has a corresponding issue.
-->

### Describe what this PR does / why we need it

This PR allows users to press `<C-b>` in the window created from `:Octo pr
checks` to open the check run in the browser.

### Does this pull request fix one issue?

<!--If that, add "Fixes #xxxx" below in the next line. For example, Fixes #15. Otherwise, add "NONE" -->

### Describe how you did it


### Describe how to verify it

`:Octo pr checks` while in PR buffer
press `<C-b>` on one of the check runs


### Special notes for reviews

### Checklist

- [ ] Passing tests and linting standards
- [ ] Documentation updates in README.md and doc/octo.txt

Patch:
diff --git a/lua/octo/commands.lua b/lua/octo/commands.lua
index da2a05d9..f9accbe3 100644
--- a/lua/octo/commands.lua
+++ b/lua/octo/commands.lua
@@ -1785,6 +1785,55 @@ function M.gh_pr_ready(opts)
   }
 end
 
+local parse_checks = function(data)
+  local checks = {}
+  for _, row in ipairs(data) do
+    checks[#checks + 1] = {
+      row.name,
+      row.bucket,
+      utils.format_seconds(row.seconds),
+      row.link,
+    }
+  end
+  return checks
+end
+
+local get_max_lengths = function(data)
+  local max_lengths = {}
+  if #data == 0 then
+    return max_lengths
+  end
+
+  -- Initialize max_lengths with zeros for each column
+  for col = 1, #data[1] do
+    max_lengths[col] = 0
+  end
+
+  for _, row in ipairs(data) do
+    for col, word in ipairs(row) do
+      local word_length = #word
+      if word_length > max_lengths[col] then
+        max_lengths[col] = word_length
+      end
+    end
+  end
+  return max_lengths
+end
+
+local format_checks = function(parts)
+  local max_lengths = get_max_lengths(parts)
+
+  local lines = {}
+  for _, p in pairs(parts) do
+    local line = {}
+    for i, pp in pairs(p) do
+      table.insert(line, pp .. (" "):rep(max_lengths[i] - #pp))
+    end
+    table.insert(lines, table.concat(line, "  "))
+  end
+  return lines
+end
+
 function M.pr_checks()
   local buffer = utils.get_current_buffer()
 
@@ -1792,30 +1841,25 @@ function M.pr_checks()
     return
   end
 
-  local function show_checks(output)
-    local max_lengths = {}
-    local parts = {}
-    for _, l in pairs(vim.split(output, "\n")) do
-      local line_parts = vim.split(l, "\t")
-      for i, p in pairs(line_parts) do
-        if max_lengths[i] == nil or max_lengths[i] < #p then
-          max_lengths[i] = #p
-        end
-      end
-      table.insert(parts, line_parts)
-    end
-    local lines = {}
-    for _, p in pairs(parts) do
-      local line = {}
-      for i, pp in pairs(p) do
-        table.insert(line, pp .. (" "):rep(max_lengths[i] - #pp))
-      end
-      table.insert(lines, table.concat(line, "  "))
-    end
+  local function show_checks(data)
+    data = vim.json.decode(data)
+    local parts = parse_checks(data)
+    local lines = format_checks(parts)
     local _, wbufnr = window.create_centered_float {
       header = "Checks",
       content = lines,
     }
+
+    vim.api.nvim_buf_set_keymap(wbufnr, "n", "<C-b>", "", {
+      noremap = true,
+      silent = true,
+      callback = function()
+        local line_number = vim.api.nvim_win_get_cursor(0)[1]
+        local url = data[line_number].link
+        navigation.open_in_browser_raw(url)
+      end,
+    })
+
     local buf_lines = vim.api.nvim_buf_get_lines(wbufnr, 0, -1, false)
     for i, l in ipairs(buf_lines) do
       if #vim.split(l, "pass") > 1 then
@@ -1830,6 +1874,10 @@ function M.pr_checks()
   gh.pr.checks {
     buffer.number,
     repo = buffer.repo,
+    json = "name,bucket,startedAt,completedAt,link",
+    jq = [[
+      map(. += {"seconds": ((.completedAt | fromdateiso8601) - (.startedAt | fromdateiso8601))})
+    ]],
     opts = {
       cb = gh.create_callback {
         success = show_checks,
diff --git a/lua/octo/utils.lua b/lua/octo/utils.lua
index c13b87fb..d10bac82 100644
--- a/lua/octo/utils.lua
+++ b/lua/octo/utils.lua
@@ -672,6 +672,28 @@ function M.format_large_int(n, is_capitalized)
   return string.format("%.1f%s", n, suffixes[i])
 end
 
+---Formats number of seconds as a duration string
+---@param seconds integer
+---@return string
+function M.format_seconds(seconds)
+  if seconds < 60 then
+    return seconds .. "s"
+  end
+  local minutes = math.floor(seconds / 60)
+  seconds = seconds % 60
+  if minutes < 60 then
+    return string.format("%dm%ds", minutes, seconds)
+  end
+  local hours = math.floor(minutes / 60)
+  minutes = minutes % 60
+  if hours < 24 then
+    return string.format("%dh%dm", hours, minutes)
+  end
+  local days = math.floor(hours / 24)
+  hours = hours % 24
+  return string.format("%dd%dh", days, hours)
+end
+
 ---Formats a string as a date
 ---@param date_string string
 ---@param round_under_one_minute? boolean defaults to true


Test patch:
diff --git a/lua/tests/plenary/utils_spec.lua b/lua/tests/plenary/utils_spec.lua
index 6befdcaf..580e1a11 100644
--- a/lua/tests/plenary/utils_spec.lua
+++ b/lua/tests/plenary/utils_spec.lua
@@ -191,3 +191,18 @@ describe("parse_url", function()
     eq(kind, "discussion")
   end)
 end)
+
+describe("format_seconds", function()
+  it("formats less than a minute", function()
+    eq(this.format_seconds(45), "45s")
+  end)
+  it("formats minutes and seconds", function()
+    eq(this.format_seconds(125), "2m5s")
+  end)
+  it("formats hours and minutes", function()
+    eq(this.format_seconds(3665), "1h1m")
+  end)
+  it("formats days and hours", function()
+    eq(this.format_seconds(90061), "1d1h")
+  end)
+end)