---------------------------------------------------------------------------- -- 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?type=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 "]]) 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", "ib", ":lua require('gitea.issues').open_or_create_branch()", { noremap = true, silent = true } ) -- Keymap to close the issue buffer quickly vim.api.nvim_buf_set_keymap( buf, "n", "iq", ":bd!", { noremap = true, silent = true } ) -- Keymap to create a pull request (ip) -- This will close the issue buffer and open the new PR buffer. vim.api.nvim_buf_set_keymap( buf, "n", "ip", ":lua require('gitea.issues').create_pull_request_for_issue()", { 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 [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