feat: first implementation for issues and pull requests
This commit is contained in:
472
lua/gitea/issues.lua
Normal file
472
lua/gitea/issues.lua
Normal file
@@ -0,0 +1,472 @@
|
||||
----------------------------------------------------------------------------
|
||||
-- lua/gitea/issues.lua
|
||||
--
|
||||
-- Manages Gitea issues and an associated YAML buffer interface.
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
local M = {}
|
||||
local core = require("gitea")
|
||||
local Job = require("plenary.job")
|
||||
local lyaml = require("lyaml") -- ensure `luarocks install lyaml`
|
||||
local pulls = require("gitea.pulls")
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- fallback_owner_repo
|
||||
----------------------------------------------------------------------------
|
||||
local function fallback_owner_repo(owner, repo)
|
||||
if (not owner or owner == "") or (not repo or repo == "") then
|
||||
owner = core._cached_owner
|
||||
repo = core._cached_repo
|
||||
end
|
||||
if not owner or not repo then
|
||||
vim.schedule(function()
|
||||
vim.cmd([[echoerr "No owner/repo provided and none found in .git/config"]])
|
||||
end)
|
||||
return nil, nil
|
||||
end
|
||||
return owner, repo
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- "Slugify" a title for branch usage
|
||||
----------------------------------------------------------------------------
|
||||
local function slugify_title(title)
|
||||
-- Lowercase the title, replace whitespace with dashes, remove invalid chars,
|
||||
-- and truncate to 50 chars just to be safe
|
||||
local slug = title:lower()
|
||||
:gsub("%s+", "-")
|
||||
:gsub("[^a-z0-9%-]", "")
|
||||
:sub(1, 50)
|
||||
return slug
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- Buffer Setup
|
||||
----------------------------------------------------------------------------
|
||||
local function set_issue_buffer_options(buf)
|
||||
vim.api.nvim_buf_set_option(buf, "buftype", "acwrite") -- allows :w
|
||||
vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe")
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- attach_issue_autocmds
|
||||
-- On save, parse entire YAML doc, update issue, post new comments.
|
||||
----------------------------------------------------------------------------
|
||||
local function attach_issue_autocmds(buf)
|
||||
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
||||
buffer = buf,
|
||||
callback = function()
|
||||
local owner = vim.api.nvim_buf_get_var(buf, "gitea_issue_owner")
|
||||
local repo = vim.api.nvim_buf_get_var(buf, "gitea_issue_repo")
|
||||
local number = vim.api.nvim_buf_get_var(buf, "gitea_issue_number")
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
local doc_str = table.concat(lines, "\n")
|
||||
|
||||
-- parse as YAML with lyaml
|
||||
local ok, docs = pcall(lyaml.load, doc_str)
|
||||
if not ok or type(docs) ~= "table" then
|
||||
vim.schedule(function()
|
||||
vim.cmd([[echoerr "Failed to parse YAML with lyaml!"]])
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- We assume docs = { issue = { ... }, comments = { ... } }
|
||||
local issue = docs.issue or {}
|
||||
local cmts = docs.comments or {}
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- PATCH the issue (title/body)
|
||||
----------------------------------------------------------------------------
|
||||
local patch_data = {}
|
||||
if type(issue.title) == "string" and issue.title ~= "" then
|
||||
patch_data.title = issue.title
|
||||
end
|
||||
if type(issue.body) == "string" then
|
||||
patch_data.body = issue.body
|
||||
end
|
||||
|
||||
local issue_ep = string.format("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
core.request("PATCH", issue_ep, patch_data, function(_, err)
|
||||
if err then
|
||||
vim.schedule(function()
|
||||
vim.cmd(('echoerr "Failed to update issue: %s"'):format(err))
|
||||
end)
|
||||
return
|
||||
end
|
||||
print("[gitea.nvim] Issue (title/body) updated.")
|
||||
end)
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- POST new comments => those lacking 'id'
|
||||
----------------------------------------------------------------------------
|
||||
if type(cmts) == "table" then
|
||||
for _, citem in ipairs(cmts) do
|
||||
local cid = citem.id
|
||||
if not cid then
|
||||
local cbody = citem.body or ""
|
||||
if #cbody > 0 then
|
||||
local c_ep = string.format("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number)
|
||||
core.request("POST", c_ep, { body = cbody }, function(resp, c_err)
|
||||
if c_err then
|
||||
vim.schedule(function()
|
||||
vim.cmd(('echoerr "Failed to add comment: %s"'):format(c_err))
|
||||
end)
|
||||
return
|
||||
end
|
||||
print("[gitea.nvim] New comment posted => " .. (resp.body or ""))
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- M.list_issues
|
||||
----------------------------------------------------------------------------
|
||||
local function list_issues(owner, repo, on_done)
|
||||
owner, repo = fallback_owner_repo(owner, repo)
|
||||
if not owner or not repo then
|
||||
return
|
||||
end
|
||||
local ep = string.format("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||
core.request("GET", ep, nil, function(data, err)
|
||||
if err then
|
||||
vim.schedule(function()
|
||||
vim.cmd(('echoerr "Failed to list issues: %s"'):format(err))
|
||||
end)
|
||||
return
|
||||
end
|
||||
if on_done then
|
||||
on_done(data)
|
||||
else
|
||||
print(string.format("Got %d issues for %s/%s", #data, owner, repo))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- M.open_issue
|
||||
-- Reversed comment display => newest on top
|
||||
----------------------------------------------------------------------------
|
||||
local function open_issue(owner, repo, number)
|
||||
owner, repo = fallback_owner_repo(owner, repo)
|
||||
if not owner or not repo or not number then
|
||||
vim.schedule(function()
|
||||
vim.cmd([[echoerr "Usage: :Gitea issue open <owner> <repo> <number>"]])
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local issue_ep = string.format("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
core.request("GET", issue_ep, nil, function(issue, err)
|
||||
if err then
|
||||
vim.schedule(function()
|
||||
vim.cmd(('echoerr "Failed to open issue: %s"'):format(err))
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local comments_ep = string.format("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number)
|
||||
core.request("GET", comments_ep, nil, function(comments, c_err)
|
||||
if c_err then
|
||||
vim.schedule(function()
|
||||
vim.cmd(('echoerr "Failed to load comments: %s"'):format(c_err))
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_name(buf, string.format("gitea://issue_%d.yaml", number))
|
||||
vim.api.nvim_buf_set_option(buf, "filetype", "yaml")
|
||||
|
||||
set_issue_buffer_options(buf)
|
||||
attach_issue_autocmds(buf)
|
||||
|
||||
vim.api.nvim_buf_set_var(buf, "gitea_issue_owner", owner)
|
||||
vim.api.nvim_buf_set_var(buf, "gitea_issue_repo", repo)
|
||||
vim.api.nvim_buf_set_var(buf, "gitea_issue_number", number)
|
||||
|
||||
local lines = {}
|
||||
table.insert(lines, "issue:")
|
||||
table.insert(lines, (" number: %d"):format(issue.number or number))
|
||||
table.insert(lines, (" title: %s"):format(issue.title or ""))
|
||||
table.insert(lines, (" state: %s"):format(issue.state or ""))
|
||||
table.insert(lines, " body: |")
|
||||
if type(issue.body) == "string" and #issue.body > 0 then
|
||||
for line in string.gmatch(issue.body, "[^\r\n]+") do
|
||||
table.insert(lines, " " .. line)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, "comments:")
|
||||
|
||||
-- newest on top => loop in reverse
|
||||
if type(comments) == "table" then
|
||||
for i = #comments, 1, -1 do
|
||||
local c = comments[i]
|
||||
local cid = c.id or 0
|
||||
local user = (c.user and c.user.login) or "unknown"
|
||||
local cbody = c.body or ""
|
||||
table.insert(lines, (" - id: %d"):format(cid))
|
||||
table.insert(lines, (" user: %s"):format(user))
|
||||
table.insert(lines, " body: |")
|
||||
if #cbody > 0 then
|
||||
for cline in string.gmatch(cbody, "[^\r\n]+") do
|
||||
table.insert(lines, " " .. cline)
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(lines, "# Add new comments by omitting 'id:'; e.g.:")
|
||||
table.insert(lines, "# - body: |")
|
||||
table.insert(lines, "# new multiline comment")
|
||||
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
|
||||
-- Keymap to open or create a branch for the issue
|
||||
vim.api.nvim_buf_set_keymap(
|
||||
buf,
|
||||
"n",
|
||||
"<leader>ib",
|
||||
":lua require('gitea.issues').open_or_create_branch()<CR>",
|
||||
{ noremap = true, silent = true }
|
||||
)
|
||||
|
||||
-- Keymap to close the issue buffer quickly
|
||||
vim.api.nvim_buf_set_keymap(
|
||||
buf,
|
||||
"n",
|
||||
"<leader>iq",
|
||||
":bd!<CR>",
|
||||
{ noremap = true, silent = true }
|
||||
)
|
||||
|
||||
-- Keymap to create a pull request (<leader>ip)
|
||||
-- This will close the issue buffer and open the new PR buffer.
|
||||
vim.api.nvim_buf_set_keymap(
|
||||
buf,
|
||||
"n",
|
||||
"<leader>ip",
|
||||
":lua require('gitea.issues').create_pull_request_for_issue()<CR>",
|
||||
{ noremap = true, silent = true }
|
||||
)
|
||||
|
||||
if core.preview_style == "split" then
|
||||
vim.cmd("vsplit")
|
||||
vim.api.nvim_set_current_buf(buf)
|
||||
else
|
||||
local width = math.floor(vim.o.columns * 0.7)
|
||||
local height = math.floor(vim.o.lines * 0.7)
|
||||
local row = math.floor((vim.o.lines - height) / 2)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
vim.api.nvim_open_win(buf, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = row,
|
||||
col = col,
|
||||
style = "minimal",
|
||||
border = "rounded"
|
||||
})
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- M.create_issue
|
||||
----------------------------------------------------------------------------
|
||||
local function create_issue(owner, repo, title, body, cb)
|
||||
owner, repo = fallback_owner_repo(owner, repo)
|
||||
if not title or title == "" then
|
||||
vim.schedule(function()
|
||||
vim.cmd([[echoerr "Usage: :Gitea issue create <title> [body...]"]])
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local ep = string.format("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||
local data = { title = title, body = body or "", labels = {} }
|
||||
core.request("POST", ep, data, function(resp, err)
|
||||
if err then
|
||||
vim.schedule(function()
|
||||
vim.cmd(('echoerr "Failed to create issue: %s"'):format(err))
|
||||
end)
|
||||
if cb then
|
||||
cb(nil, err)
|
||||
end
|
||||
return
|
||||
end
|
||||
if cb then
|
||||
cb(resp, nil)
|
||||
else
|
||||
print(string.format("Issue #%d created: %s", resp.number, resp.title))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- M.open_or_create_branch
|
||||
-- Now using issue title in branch name: `issue-<number>-<slug-of-title>`
|
||||
----------------------------------------------------------------------------
|
||||
function M.open_or_create_branch()
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
|
||||
-- Get the lines so we can parse the issue's title for the slug
|
||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
local doc_str = table.concat(lines, "\n")
|
||||
local ok, docs = pcall(lyaml.load, doc_str)
|
||||
if not ok or type(docs) ~= "table" then
|
||||
vim.schedule(function()
|
||||
vim.cmd([[echoerr "Failed to parse YAML for branch creation!"]])
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local issue = docs.issue or {}
|
||||
local title = (issue.title or "")
|
||||
local slug = slugify_title(title)
|
||||
|
||||
-- Retrieve the issue metadata from buffer variables
|
||||
local owner = vim.api.nvim_buf_get_var(buf, "gitea_issue_owner")
|
||||
local repo = vim.api.nvim_buf_get_var(buf, "gitea_issue_repo")
|
||||
local number = vim.api.nvim_buf_get_var(buf, "gitea_issue_number")
|
||||
|
||||
local branch_name = ("issue-%d-%s"):format(number, slug)
|
||||
|
||||
-- Check if the branch exists
|
||||
local job_check_branch = Job:new({
|
||||
command = "git",
|
||||
args = { "branch", "--list", branch_name },
|
||||
on_exit = function(j, return_val)
|
||||
if return_val == 0 then
|
||||
local result = j:result()
|
||||
vim.schedule(function()
|
||||
if #result > 0 then
|
||||
-- Branch already exists, just check it out
|
||||
Job:new({
|
||||
command = "git",
|
||||
args = { "checkout", branch_name },
|
||||
on_exit = function(_, ret)
|
||||
vim.schedule(function()
|
||||
if ret == 0 then
|
||||
print("[gitea.nvim] Switched to existing branch: " .. branch_name)
|
||||
else
|
||||
vim.cmd(('echoerr "Failed to checkout branch: %s"'):format(branch_name))
|
||||
end
|
||||
end)
|
||||
end
|
||||
}):start()
|
||||
else
|
||||
-- Branch does not exist, create it
|
||||
Job:new({
|
||||
command = "git",
|
||||
args = { "checkout", "-b", branch_name },
|
||||
on_exit = function(_, ret)
|
||||
vim.schedule(function()
|
||||
if ret == 0 then
|
||||
print("[gitea.nvim] Created new branch: " .. branch_name)
|
||||
else
|
||||
vim.cmd(('echoerr "Failed to create branch: %s"'):format(branch_name))
|
||||
end
|
||||
end)
|
||||
end
|
||||
}):start()
|
||||
end
|
||||
end)
|
||||
else
|
||||
vim.schedule(function()
|
||||
vim.cmd('echoerr "Failed to check local branches."')
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
job_check_branch:start()
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- M.create_pull_request_for_issue
|
||||
-- 1) Derive branch name using 'issue-<number>-<slug-of-title>'
|
||||
-- 2) If branch doesn't exist, warn user
|
||||
-- 3) If yes, create a PR from that branch to 'main', close the issue buffer,
|
||||
-- and open a new PR buffer.
|
||||
----------------------------------------------------------------------------
|
||||
function M.create_pull_request_for_issue()
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
local owner = vim.api.nvim_buf_get_var(buf, "gitea_issue_owner")
|
||||
local repo = vim.api.nvim_buf_get_var(buf, "gitea_issue_repo")
|
||||
local number = vim.api.nvim_buf_get_var(buf, "gitea_issue_number")
|
||||
|
||||
-- Parse the doc to get the issue title
|
||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
local doc_str = table.concat(lines, "\n")
|
||||
local ok, docs = pcall(lyaml.load, doc_str)
|
||||
if not ok or type(docs) ~= "table" then
|
||||
vim.cmd([[echoerr "Failed to parse YAML for pull request creation!"]])
|
||||
return
|
||||
end
|
||||
|
||||
local issue = docs.issue or {}
|
||||
local title = issue.title or ""
|
||||
local slug = slugify_title(title)
|
||||
local branch = ("issue-%d-%s"):format(number, slug)
|
||||
|
||||
-- Check if local branch exists
|
||||
local job_check_branch = Job:new({
|
||||
command = "git",
|
||||
args = { "branch", "--list", branch },
|
||||
on_exit = function(j, return_val)
|
||||
vim.schedule(function()
|
||||
if return_val ~= 0 then
|
||||
vim.cmd([[echoerr "Failed to check local branches."]])
|
||||
return
|
||||
end
|
||||
local result = j:result()
|
||||
if #result == 0 then
|
||||
-- Branch doesn't exist locally
|
||||
vim.cmd(('echoerr "Local branch `%s` does not exist. Create/open it first."'):format(branch))
|
||||
return
|
||||
end
|
||||
|
||||
-- If we reach here, the branch exists. Create the PR from `branch` to `main`.
|
||||
local pr_title = ("Issue #%d - %s"):format(number, title)
|
||||
local pr_body = ("Automatically created from branch `%s`."):format(branch)
|
||||
|
||||
pulls.create_pull(owner, repo, branch, "main", pr_title, pr_body, function(new_pr, err)
|
||||
if err then
|
||||
vim.cmd(('echoerr "Failed to create pull request: %s"'):format(err))
|
||||
return
|
||||
end
|
||||
print(string.format("[gitea.nvim] Pull request #%d created.", new_pr.number))
|
||||
|
||||
-- Close the issue buffer
|
||||
vim.cmd("bd!")
|
||||
|
||||
-- Open the new PR buffer
|
||||
pulls.open_pull_in_buffer(owner, repo, new_pr.number)
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
job_check_branch:start()
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- Exports
|
||||
----------------------------------------------------------------------------
|
||||
M.list_issues = list_issues
|
||||
M.open_issue = open_issue
|
||||
M.create_issue = create_issue
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user