From f46f60b7239ba1335376e049c737b265942ebce6 Mon Sep 17 00:00:00 2001 From: Dominik Polakovics Date: Sun, 29 Dec 2024 20:50:50 +0100 Subject: [PATCH] feat: first implementation for issues and pull requests --- .chatgpt_config.yaml | 11 + after/syntax/gitea.vim | 34 --- doc/gitea.nvim.md | 57 ++++ lua/gitea/api.lua | 226 ++++------------ lua/gitea/auth.lua | 111 -------- lua/gitea/commands.lua | 544 +++++++-------------------------------- lua/gitea/config.lua | 25 +- lua/gitea/highlights.lua | 58 ----- lua/gitea/init.lua | 248 +++++++++++++++++- lua/gitea/issues.lua | 472 +++++++++++++++++++++++++++++++++ lua/gitea/pulls.lua | 261 +++++++++++++++++++ lua/gitea/telescope.lua | 50 ++++ plugin/gitea.vim | 10 + 13 files changed, 1248 insertions(+), 859 deletions(-) delete mode 100644 after/syntax/gitea.vim create mode 100644 doc/gitea.nvim.md delete mode 100644 lua/gitea/auth.lua delete mode 100644 lua/gitea/highlights.lua create mode 100644 lua/gitea/issues.lua create mode 100644 lua/gitea/pulls.lua create mode 100644 lua/gitea/telescope.lua create mode 100644 plugin/gitea.vim diff --git a/.chatgpt_config.yaml b/.chatgpt_config.yaml index c16601d..4de65e9 100644 --- a/.chatgpt_config.yaml +++ b/.chatgpt_config.yaml @@ -1,3 +1,14 @@ project_name: "gitea.nvim" default_prompt_blocks: - "basic-prompt" + +preview_changes: false +interactive_file_selection: false +partial_acceptance: false +improved_debug: true + +# We rely on token_limit to decide chunk sizes +token_limit: 128000 + +# Enable chunking if we exceed the token limit +enable_chunking: true diff --git a/after/syntax/gitea.vim b/after/syntax/gitea.vim deleted file mode 100644 index 2f4cdae..0000000 --- a/after/syntax/gitea.vim +++ /dev/null @@ -1,34 +0,0 @@ -" quit when a syntax file was already loaded -if exists("b:current_syntax") - finish -endif - -if !exists('main_syntax') - let main_syntax = 'gitea' -endif - -runtime! syntax/markdown.vim ftplugin/markdown.vim ftplugin/markdown_*.vim ftplugin/markdown/*.vim -unlet! b:current_syntax - -" Emoji conceal, similar to octo.nvim but for 'gitea' -call matchadd('Conceal', ':heart:', 10, -1, {'conceal':'❀️'}) -call matchadd('Conceal', ':+1:', 10, -1, {'conceal':'πŸ‘'}) -call matchadd('Conceal', ':see_no_evil:', 10, -1, {'conceal':'πŸ™ˆ'}) -call matchadd('Conceal', ':laughing:', 10, -1, {'conceal':'πŸ˜†'}) -call matchadd('Conceal', ':thinking_face:', 10, -1, {'conceal':'πŸ€”'}) -call matchadd('Conceal', ':thinking:', 10, -1, {'conceal':'πŸ€”'}) -call matchadd('Conceal', ':ok_hand:', 10, -1, {'conceal':'πŸ‘Œ'}) -call matchadd('Conceal', ':upside_down_face:', 10, -1, {'conceal':'πŸ™ƒ'}) -call matchadd('Conceal', ':grimacing:', 10, -1, {'conceal':'😬'}) -call matchadd('Conceal', ':rocket:', 10, -1, {'conceal':'πŸš€'}) -call matchadd('Conceal', ':blush:', 10, -1, {'conceal':'😊'}) -call matchadd('Conceal', ':tada:', 10, -1, {'conceal':'πŸŽ‰'}) -call matchadd('Conceal', ':shrug:', 10, -1, {'conceal':'🀷'}) -call matchadd('Conceal', ':man_shrugging:', 10, -1, {'conceal':'🀷'}) -call matchadd('Conceal', ':face_palm:', 10, -1, {'conceal':'🀦'}) -call matchadd('Conceal', ':man_facepalmin:', 10, -1, {'conceal':'🀦'}) - -let b:current_syntax = "gitea" -if main_syntax ==# 'gitea' - unlet main_syntax -endif diff --git a/doc/gitea.nvim.md b/doc/gitea.nvim.md new file mode 100644 index 0000000..8de98c9 --- /dev/null +++ b/doc/gitea.nvim.md @@ -0,0 +1,57 @@ +# gitea.nvim + +**gitea.nvim** allows you to manage [Gitea](https://gitea.io/) issues and pull requests from Neovim, +inspired by [octo.nvim](https://github.com/pwntester/octo.nvim). + +## Features +- Stores a Gitea token securely, optionally adding it to `.gitignore`. +- Tries to auto-detect the Gitea host from your `.git/config` or prompts for it. +- List, open, comment, and create issues. +- List, open, comment, and merge pull requests. +- Create a local Git branch for an issue. + +## Installation + +Using [packer.nvim](https://github.com/wbthomason/packer.nvim): + +```lua +use { + 'youruser/gitea.nvim', + requires = { 'nvim-lua/plenary.nvim' }, + config = function() + require('gitea').setup() + end +} +``` + +## Configuration + +```lua +require('gitea').setup({ + base_url = "https://gitea.example.com", + token_file = vim.fn.stdpath('config') .. "/.gitea_token", + preview_style = "floating", -- or "split" +}) +``` + +## Usage (Commands) + +- `:GiteaListIssues ` +- `:GiteaOpenIssue ` +- `:GiteaCreateIssue <body...>` +- `:GiteaListComments <owner> <repo> <issue_number>` +- `:GiteaAddComment <owner> <repo> <issue_number> <comment...>` +- `:GiteaCreateBranchForIssue <owner> <repo> <issue_number>` +- `:GiteaListPRs <owner> <repo>` +- `:GiteaOpenPR <owner> <repo> <pr_number>` +- `:GiteaCommentPR <owner> <repo> <pr_number> <comment...>` +- `:GiteaMergePR <owner> <repo> <pr_number>` + +## Common Issues + +- **Circular Dependency**: Previously, `commands.lua` required `init.lua` while `init.lua` required `commands.lua`. + The updated approach removes that loop by passing the plugin’s table to `commands.setup_commands`. + +## License + +MIT License diff --git a/lua/gitea/api.lua b/lua/gitea/api.lua index a077c27..f0ccea1 100644 --- a/lua/gitea/api.lua +++ b/lua/gitea/api.lua @@ -1,196 +1,64 @@ -local config = require("gitea.config") -local auth = require("gitea.auth") -local curl = require("plenary.curl") +local Job = require('plenary.job') +local api = {} -local M = {} +-- Helper function for making async requests to Gitea API via curl +local function request(method, base_url, endpoint, token, data, callback) + local headers = { + "Content-Type: application/json", + "Authorization: token " .. token + } ------------------------------------------------------------------------------- --- Helper: get base URL from config ------------------------------------------------------------------------------- -local function get_base_url() - return config.values.server_url -end + local curl_args = { + "-s", -- silent mode + "-X", method, -- GET, POST, PATCH, etc. + base_url .. endpoint, + "-H", headers[1], + "-H", headers[2] + } ------------------------------------------------------------------------------- --- Helper: get auth header by trimming token ------------------------------------------------------------------------------- -local function get_auth_header() - local token = auth.get_token() - if not token or token == "" then - error("[gitea.nvim] Missing Gitea token.") - end - return "token " .. token -end - ------------------------------------------------------------------------------- --- Main HTTP request helper (using plenary.curl) ------------------------------------------------------------------------------- -local function request(method, endpoint, opts) - opts = opts or {} - local url = get_base_url() .. endpoint - local headers = opts.headers or {} - headers["Authorization"] = get_auth_header() - headers["Content-Type"] = "application/json" - - local body_data - if opts.body then - body_data = vim.json.encode(opts.body) + if data then + local json_data = vim.fn.json_encode(data) + table.insert(curl_args, "-d") + table.insert(curl_args, json_data) end - local result = curl.request({ - url = url, - method = method, - headers = headers, - timeout = 10000, - body = body_data, - query = opts.query, - }) - - return result + Job:new({ + command = "curl", + args = curl_args, + on_exit = function(j, return_val) + if return_val == 0 then + local result = table.concat(j:result(), "\n") + local ok, decoded = pcall(vim.fn.json_decode, result) + if ok then + callback(decoded, nil) + else + callback(nil, "Failed to decode JSON. Response: " .. result) + end + else + callback(nil, "Curl request failed with return code: " .. return_val) + end + end + }):start() end ------------------------------------------------------------------------------- --- Issues ------------------------------------------------------------------------------- -function M.list_issues(owner, repo, opts) - local endpoint = string.format("/api/v1/repos/%s/%s/issues", owner, repo) - local result = request("GET", endpoint, { query = opts }) - if result and result.status == 200 then - return vim.json.decode(result.body) - end - return nil, result and result.status +function api.get(base_url, endpoint, token, cb) + request("GET", base_url, endpoint, token, nil, cb) end -function M.get_issue(owner, repo, number) - local endpoint = string.format("/api/v1/repos/%s/%s/issues/%d", owner, repo, number) - local result = request("GET", endpoint) - if result and result.status == 200 then - return vim.json.decode(result.body) - end - return nil, result and result.status +function api.post(base_url, endpoint, token, data, cb) + request("POST", base_url, endpoint, token, data, cb) end -function M.create_issue(owner, repo, data) - local endpoint = string.format("/api/v1/repos/%s/%s/issues", owner, repo) - local result = request("POST", endpoint, { body = data }) - if result and result.status == 201 then - return vim.json.decode(result.body) - end - return nil, result and result.status +function api.patch(base_url, endpoint, token, data, cb) + request("PATCH", base_url, endpoint, token, data, cb) end --- CHANGED: treat both 200 and 201 as success, in case Gitea returns 201 -function M.edit_issue(owner, repo, number, data) - local endpoint = string.format("/api/v1/repos/%s/%s/issues/%d", owner, repo, number) - local result = request("PATCH", endpoint, { body = data }) - if result and (result.status == 200 or result.status == 201) then - return vim.json.decode(result.body) - end - return nil, result and result.status +function api.put(base_url, endpoint, token, data, cb) + request("PUT", base_url, endpoint, token, data, cb) end -function M.close_issue(owner, repo, number) - return M.edit_issue(owner, repo, number, { state = "closed" }) +function api.delete(base_url, endpoint, token, cb) + request("DELETE", base_url, endpoint, token, nil, cb) end -function M.reopen_issue(owner, repo, number) - return M.edit_issue(owner, repo, number, { state = "open" }) -end - -function M.comment_issue(owner, repo, number, body) - local endpoint = string.format("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number) - local result = request("POST", endpoint, { body = { body = body } }) - if result and result.status == 201 then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - --- CHANGED: treat both 200 and 201 as success -function M.edit_issue_comment(owner, repo, number, comment_id, body) - local endpoint = string.format("/api/v1/repos/%s/%s/issues/comments/%d", owner, repo, comment_id) - local result = request("PATCH", endpoint, { body = { body = body } }) - if result and (result.status == 200 or result.status == 201) then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - -function M.get_issue_comments(owner, repo, number) - local endpoint = string.format("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number) - local result = request("GET", endpoint) - if result and result.status == 200 then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - ------------------------------------------------------------------------------- --- Pull Requests ------------------------------------------------------------------------------- -function M.list_pull_requests(owner, repo, opts) - local endpoint = string.format("/api/v1/repos/%s/%s/pulls", owner, repo) - local result = request("GET", endpoint, { query = opts }) - if result and result.status == 200 then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - -function M.get_pull_request(owner, repo, number) - local endpoint = string.format("/api/v1/repos/%s/%s/pulls/%d", owner, repo, number) - local result = request("GET", endpoint) - if result and result.status == 200 then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - -function M.create_pull_request(owner, repo, data) - local endpoint = string.format("/api/v1/repos/%s/%s/pulls", owner, repo) - local result = request("POST", endpoint, { body = data }) - if result and result.status == 201 then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - --- CHANGED: treat both 200 and 201 as success for PR edits -function M.edit_pull_request(owner, repo, number, data) - local endpoint = string.format("/api/v1/repos/%s/%s/pulls/%d", owner, repo, number) - local result = request("PATCH", endpoint, { body = data }) - if result and (result.status == 200 or result.status == 201) then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - -function M.merge_pull_request(owner, repo, number, merge_style, merge_title, merge_message) - local endpoint = string.format("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, number) - local result = request("POST", endpoint, { - body = { - Do = merge_style or "merge", - MergeTitleField = merge_title or "", - MergeMessageField = merge_message or "", - } - }) - if result and result.status == 200 then - return vim.json.decode(result.body) - end - return nil, result and result.status -end - -function M.close_pull_request(owner, repo, number) - return M.edit_pull_request(owner, repo, number, { state = "closed" }) -end - -function M.reopen_pull_request(owner, repo, number) - return M.edit_pull_request(owner, repo, number, { state = "open" }) -end - -function M.comment_pull_request(owner, repo, number, body) - -- Uses same logic as comment_issue - return M.comment_issue(owner, repo, number, body) -end - -return M +return api diff --git a/lua/gitea/auth.lua b/lua/gitea/auth.lua deleted file mode 100644 index 049e690..0000000 --- a/lua/gitea/auth.lua +++ /dev/null @@ -1,111 +0,0 @@ -local config = require("gitea.config") -local uv = vim.loop - -local M = {} -local token_cached = nil - --- Read token file from disk (if present) -local function read_token_file(path) - local fd = uv.fs_open(path, "r", 438) - if not fd then return nil end - local stat = uv.fs_fstat(fd) - local data = uv.fs_read(fd, stat.size, 0) - uv.fs_close(fd) - return data -end - --- Write token file to disk with restricted permissions -local function write_token_file(path, token) - local fd = uv.fs_open(path, "w", 384) -- 0600 decimal - if not fd then - error("[gitea.nvim] Failed to open token file for writing: " .. path) - end - uv.fs_write(fd, token, -1) - uv.fs_close(fd) -end - ----------------------------------------------------------------------------- ---- Attempt to detect the Gitea server from remote.origin.url or fail ----------------------------------------------------------------------------- -local function detect_gitea_server() - -- Use `git config remote.origin.url` - local cmd = "git config remote.origin.url" - local url = vim.fn.systemlist(cmd)[1] - if vim.v.shell_error ~= 0 or not url or url == "" then - return nil, "No valid remote.origin.url found" - end - - -- Remove trailing ".git" if present - url = url:gsub("%.git$", "") - - -- Check for HTTPS style: https://my.gitea.com/owner/repo - local protocol, domain_and_path = url:match("^(https?://)(.*)$") - if protocol and domain_and_path then - local server = domain_and_path:match("^([^/]+)") - if server and server ~= "" then - return protocol .. server - end - end - - -- Check for SSH style: git@my.gitea.com:owner/repo - local user_host, path = url:match("^(.-):(.*)$") - if user_host and user_host:match(".*@") then - local host = user_host:match("@(.*)") - if host and host ~= "" then - -- We'll assume https for Gitea, if your instance is not https, - -- you can adapt or forcibly use "http://" below - return "https://" .. host - end - end - - return nil, "Unable to parse Gitea host from remote.origin.url: " .. url -end - ----------------------------------------------------------------------------- ---- DO NOT PROMPT for server_url, do not fallback to "gitea.example.com" ---- If we cannot auto-detect, we throw an error. ----------------------------------------------------------------------------- -local function ensure_server_url() - local server, err = detect_gitea_server() - if not server then - error(string.format("[gitea.nvim] Failed to auto-detect Gitea server: %s", err)) - end - config.values.server_url = server -end - ----------------------------------------------------------------------------- ---- The user must have a Gitea token, we read from disk or error if missing ----------------------------------------------------------------------------- -local function ensure_token_file() - local token_file = config.values.config_file - local token_data = read_token_file(token_file) - if token_data and token_data ~= "" then - -- Trim leading/trailing whitespace/newlines so we don't send a trailing "\n" - token_data = token_data:gsub("^%s+", ""):gsub("%s+$", "") - token_cached = token_data - return - end - error( - "[gitea.nvim] No token found in " .. token_file .. - ". Please manually create and store your Gitea token there with mode 600." - ) -end - ----------------------------------------------------------------------------- ---- Called by the plugin during setup or first usage ----------------------------------------------------------------------------- -function M.ensure_token() - if token_cached then - return token_cached - end - - ensure_server_url() - ensure_token_file() - return token_cached -end - -function M.get_token() - return token_cached -end - -return M diff --git a/lua/gitea/commands.lua b/lua/gitea/commands.lua index ddc3ce3..5fdc444 100644 --- a/lua/gitea/commands.lua +++ b/lua/gitea/commands.lua @@ -1,474 +1,128 @@ +---------------------------------------------------------------------------- +-- lua/gitea/commands.lua +-- +-- Single :Gitea command with subcommands for issues, PRs, etc. +-- Removed "pr open" as requested. Only "list" and "create" remain for PR. +---------------------------------------------------------------------------- + local M = {} -local config = require("gitea.config") -local api = require("gitea.api") -local auth = require("gitea.auth") -local highlights = require("gitea.highlights") +-------------------------------------------------------------------------- +-- A simple completion function for the :Gitea command +-------------------------------------------------------------------------- +function M._gitea_cmd_complete(arg_lead, cmd_line, cursor_pos) + local tokens = vim.split(cmd_line, "%s+") ------------------------------------------------------------------------------- --- Debug toggle ------------------------------------------------------------------------------- -local DEBUG = true - ------------------------------------------------------------------------------- --- We’ll create a namespace for our extmarks ------------------------------------------------------------------------------- -local GITEA_NS = vim.api.nvim_create_namespace("gitea_nvim") - ------------------------------------------------------------------------------- --- Utility: get all lines from the region defined by an extmark ------------------------------------------------------------------------------- -local function get_extmark_region(bufnr, extmark_id) - -- extmark lookup with "details = true" returns: { row, col, details={ end_row=..., end_col=... } } - local markinfo = vim.api.nvim_buf_get_extmark_by_id(bufnr, GITEA_NS, extmark_id, { details = true }) - if not markinfo or #markinfo == 0 then - return {}, 0, 0 -- no lines + -- If the user hasn't typed anything after ":Gitea", suggest top-level words + if #tokens <= 1 then + return { "issue", "pr" } end - local start_line = markinfo[1] - local end_line = markinfo[3].end_row - local lines = vim.api.nvim_buf_get_lines(bufnr, start_line, end_line + 1, false) - return lines, start_line, end_line -end ------------------------------------------------------------------------------- --- Utility: set an extmark covering a region from start_line..end_line ------------------------------------------------------------------------------- -local function set_extmark_range(bufnr, start_line, end_line) - -- We store the extmark covering these lines from start_line..end_line - -- We do end_col=0 to anchor at column 0, but the end_row is inclusive - -- so we do { end_line, -1 } or end_col=0 with end_row=that line + 1 - local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, GITEA_NS, start_line, 0, { - end_line = end_line, - end_col = 0, - }) - return extmark_id -end + local main_sub = tokens[2] ------------------------------------------------------------------------------- --- DETECT OWNER/REPO ------------------------------------------------------------------------------- -local function detect_owner_repo() - local cmd = "git config remote.origin.url" - local url = vim.fn.systemlist(cmd)[1] - if vim.v.shell_error ~= 0 or not url or url == "" then - return nil, nil - end - url = url:gsub("%.git$", "") - local _, after = url:match("^(https?://)(.*)$") - if after then - local path = after:match("[^/]+/(.*)$") - if not path then - return nil, nil - end - local owner, repo = path:match("^(.-)/(.*)$") - return owner, repo - end - local user_host, path = url:match("^(.-):(.*)$") - if user_host and path then - local owner, repo = path:match("^(.-)/(.*)$") - return owner, repo - end - return nil, nil -end - ------------------------------------------------------------------------------- --- We’ll store "metadata" in a table like octo.nvim’s approach: --- metadata.title_extmark -> ID --- metadata.body_extmark -> ID --- metadata.comments = { { id=..., extmark_id=... }, ... } ------------------------------------------------------------------------------- -local function create_issue_buffer_metadata(issue) - return { - issue_number = issue.number, - title_extmark = nil, - body_extmark = nil, - comments = {}, - new_comment_extmark = nil, -- region for new comment - } -end - ------------------------------------------------------------------------------- --- APPLY CUSTOM HIGHLIGHTS ------------------------------------------------------------------------------- -local function apply_issue_highlights(bufnr) - -- We can still highlight lines #0 and #1, or do it differently - vim.api.nvim_buf_add_highlight(bufnr, 0, "GiteaIssueMeta", 0, 0, -1) - vim.api.nvim_buf_add_highlight(bufnr, 0, "GiteaIssueMeta", 1, 0, -1) -end - ------------------------------------------------------------------------------- --- RENDER ISSUE INTO BUFFER (octo.nvim style) ------------------------------------------------------------------------------- -local function render_issue_into_buf(bufnr, issue, comments) - -- Clear buffer, extmarks, etc. - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) - vim.api.nvim_buf_clear_namespace(bufnr, GITEA_NS, 0, -1) - - local meta = create_issue_buffer_metadata(issue) - local lines = {} - - -- Some short header lines - table.insert(lines, string.format("# Gitea Issue #%d: %s", issue.number, issue.title)) - table.insert(lines, "# STATE: " .. (issue.state or "unknown")) - table.insert(lines, "# You can edit below. On save, the changes are applied.") - table.insert(lines, "") - - -- Add placeholder for title - local title_start = #lines - table.insert(lines, issue.title or "") - local title_end = #lines - - -- blank line - table.insert(lines, "") - - -- Add placeholder for body - local body_start = #lines - local body_text = issue.body or "" - if body_text == "" then - body_text = "No issue description." - end - local body_lines = vim.split(body_text, "\n", true) - if #body_lines == 0 then - table.insert(lines, "") - else - for _, line in ipairs(body_lines) do - table.insert(lines, line) - end - end - local body_end = #lines - - -- blank line - table.insert(lines, "") - table.insert(lines, "# --- Comments ---") - - local comment_meta = {} - for _, c in ipairs(comments or {}) do - table.insert(lines, "") - local start_line = #lines - local author = c.user and (c.user.login or c.user.username) or "?" - table.insert(lines, string.format("COMMENT #%d by %s", c.id, author)) - - local c_body_lines = vim.split(c.body or "", "\n", true) - if #c_body_lines == 0 then - table.insert(lines, "") - else - for _, cb in ipairs(c_body_lines) do - table.insert(lines, cb) + if #tokens == 2 then + local candidates = { "issue", "pr" } + local results = {} + for _, c in ipairs(candidates) do + if c:find("^" .. arg_lead) then + table.insert(results, c) end end - local end_line = #lines - table.insert(comment_meta, { id = c.id, extmark_id = nil, start_line = start_line, end_line = end_line }) + return results end - -- final blank line for new comment - table.insert(lines, "") - table.insert(lines, "# --- Add a new comment below (multiline ok). ---") - local new_comment_start = #lines + 1 - table.insert(lines, "") -- at least one blank line - - -- Actually write out - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - - -- Now create extmark for the title - -- NOTE: extmark is zero-based, so set_extmark_range expects 0-based line indexes - meta.title_extmark = set_extmark_range(bufnr, title_start - 1, title_end - 1) - - -- extmark for body - meta.body_extmark = set_extmark_range(bufnr, body_start - 1, body_end - 1) - - -- extmarks for comments - for _, cmeta in ipairs(comment_meta) do - local mark_id = set_extmark_range(bufnr, cmeta.start_line - 1, cmeta.end_line - 1) - cmeta.extmark_id = mark_id + -- If "issue" was selected, next subcommands can be list, create + if main_sub == "issue" then + if #tokens == 3 then + local candidates = { "list", "create" } + local results = {} + for _, c in ipairs(candidates) do + if c:find("^" .. arg_lead) then + table.insert(results, c) + end + end + return results + end + return {} end - meta.comments = comment_meta - -- extmark for new comment region - meta.new_comment_extmark = set_extmark_range(bufnr, new_comment_start - 1, #lines - 1) + -- If "pr" was selected, next subcommands can be list, create + -- (removed "open" and "merge" as subcommands) + if main_sub == "pr" then + if #tokens == 3 then + local candidates = { "list", "create" } + local results = {} + for _, c in ipairs(candidates) do + if c:find("^" .. arg_lead) then + table.insert(results, c) + end + end + return results + end + return {} + end - apply_issue_highlights(bufnr) - return meta + return {} end ------------------------------------------------------------------------------- --- ON SAVE => PARSE & UPDATE (octo.nvim style) ------------------------------------------------------------------------------- -local function on_issue_buf_write(bufnr, metadata, owner, repo) - local current_issue_data = api.get_issue(owner, repo, metadata.issue_number) - if not current_issue_data then - vim.notify("[gitea.nvim] Could not re-fetch the issue", vim.log.levels.ERROR) - return - end +function M.setup_commands(core) + local issues_mod = require("gitea.issues") + local telescope_mod = require("gitea.telescope") + -- local pulls_mod = require("gitea.pulls") -- if needed - -- 1) Gather new title - local title_lines = get_extmark_region(bufnr, metadata.title_extmark) - local new_title = table.concat(title_lines, "\n") - -- Trim whitespace from the title (since GitHub disallows multi-line titles) - new_title = new_title:gsub("^%s+", ""):gsub("%s+$", ""):gsub("[\n\r]+", " ") + vim.api.nvim_create_user_command("Gitea", function(opts) + local args = vim.split(opts.args, "%s+") + local main = args[1] or "" - -- 2) Gather new body - local body_lines = get_extmark_region(bufnr, metadata.body_extmark) - local new_body = table.concat(body_lines, "\n") -- multiline is fine, do not trim + if main == "issue" then + local sub = args[2] or "" + if sub == "list" then + -- :Gitea issue list + telescope_mod.list_issues_in_telescope() - if DEBUG then - print("DEBUG: new_title = ", new_title) - print("DEBUG: new_body (lines) = ", vim.inspect(body_lines)) - end + elseif sub == "create" then + -- :Gitea issue create [owner] [repo] <title> ... + if #args < 3 then + vim.cmd([[echoerr "Usage: :Gitea issue create <title> [body...]"]]) + return + end + local owner, repo, title, body + if #args >= 5 then + owner = args[3] + repo = args[4] + title = args[5] + body = table.concat(vim.list_slice(args, 6), " ") + else + title = args[3] + body = table.concat(vim.list_slice(args, 4), " ") + end + issues_mod.create_issue(owner, repo, title, body) - local changed_title = (current_issue_data.title or "") ~= new_title - local changed_body = (current_issue_data.body or "") ~= new_body - - -- 3) Gather updates for existing comments - local changed_comments = {} - for _, cinfo in ipairs(metadata.comments) do - local comment_lines = get_extmark_region(bufnr, cinfo.extmark_id) - -- The first line might be "COMMENT #ID by Author" - -- So let's separate that from the actual text - local c_header = comment_lines[1] or "" - local c_body_lines = {} - for i = 2, #comment_lines do - table.insert(c_body_lines, comment_lines[i]) - end - local new_comment_body = table.concat(c_body_lines, "\n") - - table.insert(changed_comments, { - id = cinfo.id, - new_body = new_comment_body, - }) - end - - -- 4) Possibly new comment - local extra_comment_lines = get_extmark_region(bufnr, metadata.new_comment_extmark) - -- remove lines that are just comment-hint if you want, but we can accept them all - local new_comment_body = table.concat(extra_comment_lines, "\n"):gsub("^%s+", ""):gsub("%s+$", "") - local create_new_comment = #new_comment_body > 0 - - if DEBUG then - print("DEBUG: new_comment_body = ", new_comment_body) - end - - -- Validate title - if changed_title and new_title == "" then - vim.notify("[gitea.nvim] Title cannot be empty. Skipping.", vim.log.levels.ERROR) - if not changed_body and #changed_comments == 0 and not create_new_comment then - return - end - changed_title = false - end - - -- Edit issue if needed - if changed_title or changed_body then - local updated, st = api.edit_issue(owner, repo, metadata.issue_number, { - title = changed_title and new_title or current_issue_data.title, - body = changed_body and new_body or current_issue_data.body, - }) - if updated then - vim.notify("[gitea.nvim] Issue updated.") - else - vim.notify(string.format("[gitea.nvim] Failed to update issue (HTTP %s).", st or "?"), vim.log.levels.ERROR) - end - end - - -- Update each existing comment - for _, c in ipairs(changed_comments) do - if (c.new_body or "") ~= "" then - local updated_comment, st = api.edit_issue_comment(owner, repo, metadata.issue_number, c.id, c.new_body) - if not updated_comment then - vim.notify(string.format("[gitea.nvim] Failed to update comment %d", c.id), vim.log.levels.ERROR) else - vim.notify(string.format("[gitea.nvim] Comment #%d updated.", c.id)) + print("Unknown issue subcommand: " .. sub) + print("Available subcommands: list, create") + end + + elseif main == "pr" then + local sub = args[2] or "" + if sub == "list" then + vim.cmd([[echo "TODO: :Gitea pr list"]]) + elseif sub == "create" then + vim.cmd([[echo "TODO: :Gitea pr create"]]) + else + print("Unknown PR subcommand: " .. sub) + print("Available subcommands: list, create") end - end - end - -- Possibly create new comment - if create_new_comment then - local created, st = api.comment_issue(owner, repo, metadata.issue_number, new_comment_body) - if created then - vim.notify("[gitea.nvim] New comment posted.") else - vim.notify(string.format("[gitea.nvim] Failed to create comment (HTTP %s).", st or "?"), vim.log.levels.ERROR) + print("Usage: :Gitea <issue|pr> <subcommand> [arguments...]") + print("For issues: list, create") + print("For PRs: list, create") end - end -end - ------------------------------------------------------------------------------- --- OPEN ISSUE BUFFER ------------------------------------------------------------------------------- -local function open_full_issue_buffer(owner, repo, number) - local issue_data, status = api.get_issue(owner, repo, number) - if not issue_data then - vim.notify(string.format("[gitea.nvim] Error retrieving issue #%d (HTTP %s).", number, status or "?"), vim.log.levels.ERROR) - return - end - local all_comments, cstatus = api.get_issue_comments(owner, repo, number) - if not all_comments then - vim.notify(string.format("[gitea.nvim] Error retrieving issue #%d comments (HTTP %s).", number, cstatus or "?"), vim.log.levels.ERROR) - return - end - - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_option(buf, "buftype", "acwrite") - vim.api.nvim_buf_set_option(buf, "filetype", "gitea") - vim.api.nvim_buf_set_name(buf, string.format("gitea_issue_full_%d", number)) - - highlights.setup() - - -- Render lines & create extmarks - local metadata = render_issue_into_buf(buf, issue_data, all_comments) - - -- Hook saving => parse extmarks & do updates - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = buf, - callback = function() - on_issue_buf_write(buf, metadata, owner, repo) - end - }) - - vim.api.nvim_set_current_buf(buf) -end - ------------------------------------------------------------------------------- --- SUBCOMMANDS ------------------------------------------------------------------------------- -local subcommands = {} - -subcommands.issue = { - list = function(_) - local owner, repo = detect_owner_repo() - if not owner or not repo then - vim.notify("[gitea.nvim] Could not detect owner/repo.", vim.log.levels.ERROR) - return - end - local has_telescope, _ = pcall(require, "telescope") - if not has_telescope then - vim.notify("[gitea.nvim] telescope.nvim is not installed", vim.log.levels.ERROR) - return - end - - local pickers = require("telescope.pickers") - local finders = require("telescope.finders") - local conf = require("telescope.config").values - local actions = require("telescope.actions") - local action_state = require("telescope.actions.state") - - local issues, st = api.list_issues(owner, repo, {}) - if not issues then - vim.notify(string.format("[gitea.nvim] Error fetching issues (HTTP %s).", st or "?"), vim.log.levels.ERROR) - return - end - - pickers.new({}, { - prompt_title = string.format("Issues: %s/%s", owner, repo), - finder = finders.new_table { - results = issues, - entry_maker = function(issue) - return { - value = issue, - display = string.format("#%d %s", issue.number, issue.title), - ordinal = issue.number .. " " .. issue.title - } - end - }, - sorter = conf.generic_sorter({}), - attach_mappings = function(prompt_bufnr, map) - local function select_issue() - local selection = action_state.get_selected_entry() - actions.close(prompt_bufnr) - if not selection or not selection.value then - return - end - open_full_issue_buffer(owner, repo, selection.value.number) - end - map("i", "<CR>", select_issue) - map("n", "<CR>", select_issue) - return true - end, - }):find() - end, - - show = function(args) - local number = tonumber(args[1]) - if not number then - vim.notify("[gitea.nvim] Usage: :Gitea issue show <number>", vim.log.levels.ERROR) - return - end - local owner, repo = detect_owner_repo() - if not owner or not repo then - vim.notify("[gitea.nvim] Could not detect owner/repo.", vim.log.levels.ERROR) - return - end - open_full_issue_buffer(owner, repo, number) - end, -} - -subcommands.pr = { - -- If you have PR subcommands, add them here -} - ------------------------------------------------------------------------------- --- REGISTER ------------------------------------------------------------------------------- -function M.register() - vim.api.nvim_create_user_command("Gitea", function(cmd_opts) - auth.ensure_token() - local args = {} - for w in string.gmatch(cmd_opts.args, "%S+") do - table.insert(args, w) - end - local object = args[1] - local action = args[2] - if not object then - print("Usage: :Gitea <object> <action> [args]") - print("Examples:") - print(" :Gitea issue list") - print(" :Gitea issue show 123") - return - end - local sc = subcommands[object] - if not sc then - vim.notify("[gitea.nvim] Unknown object: " .. object, vim.log.levels.ERROR) - return - end - if not action then - vim.notify("[gitea.nvim] Please specify an action for " .. object, vim.log.levels.ERROR) - return - end - local fn = sc[action] - if not fn then - vim.notify(string.format("[gitea.nvim] Unknown action '%s' for object '%s'", action, object), vim.log.levels.ERROR) - return - end - table.remove(args, 1) - table.remove(args, 1) - fn(args) end, { nargs = "*", - complete = function(arg_lead, cmd_line, _) - local parts = vim.split(cmd_line, "%s+") - if #parts == 2 then - local objs = {} - for k, _ in pairs(subcommands) do - if k:match("^" .. arg_lead) then - table.insert(objs, k) - end - end - return objs - elseif #parts == 3 then - local object = parts[2] - local acts = {} - if subcommands[object] then - for k, _ in pairs(subcommands[object]) do - if k:match("^" .. arg_lead) then - table.insert(acts, k) - end - end - end - return acts - else - return {} - end - end, + desc = "Unified Gitea command with subcommands", + complete = M._gitea_cmd_complete, }) end diff --git a/lua/gitea/config.lua b/lua/gitea/config.lua index e02ed5e..727c8b0 100644 --- a/lua/gitea/config.lua +++ b/lua/gitea/config.lua @@ -1,22 +1,9 @@ -local Config = {} +local M = {} --- Default settings (user can override in setup{}) -local defaults = { - config_file = ".gitea_nvim_token", -- local file containing the token - ignore_file = ".gitignore", -- file to which we can append the token file - server_url = nil, -- extracted from .git remote if possible +M.default_config = { + base_url = nil, -- The plugin attempts to detect from .git config or prompt if not set + token_file = vim.fn.stdpath('config') .. '/.gitea_token', + preview_style = "floating", -- or "split" } -Config.values = {} - -function Config.setup(user_opts) - for k, v in pairs(defaults) do - if user_opts[k] ~= nil then - Config.values[k] = user_opts[k] - else - Config.values[k] = v - end - end -end - -return Config +return M diff --git a/lua/gitea/highlights.lua b/lua/gitea/highlights.lua deleted file mode 100644 index 7947acc..0000000 --- a/lua/gitea/highlights.lua +++ /dev/null @@ -1,58 +0,0 @@ -local M = {} - --- A small Dracula-like palette, adapt or extend if needed -local palette = { - bg = "#282A36", - fg = "#F8F8F2", - selection = "#44475A", - comment = "#6272A4", - cyan = "#8BE9FD", - green = "#50FA7B", - orange = "#FFB86C", - pink = "#FF79C6", - purple = "#BD93F9", - red = "#FF5555", - yellow = "#F1FA8C", -} - -function M.setup() - -- Title of an issue or PR - vim.api.nvim_set_hl(0, "GiteaIssueTitle", { - fg = palette.pink, - bold = true, - }) - - -- Meta lines, e.g. status lines or extra info - vim.api.nvim_set_hl(0, "GiteaIssueMeta", { - fg = palette.comment, - italic = true, - }) - - -- Heading for each comment block - vim.api.nvim_set_hl(0, "GiteaCommentHeading", { - fg = palette.orange, - bold = true, - }) - - -- The actual comment body text - vim.api.nvim_set_hl(0, "GiteaCommentBody", { - fg = palette.fg, - bg = nil, -- or palette.bg if you want a background - }) - - -- Something for a user mention - vim.api.nvim_set_hl(0, "GiteaUser", { - fg = palette.cyan, - bold = true, - }) - - -- If you'd like comment lines in a faint color - vim.api.nvim_set_hl(0, "GiteaInlineComment", { - fg = palette.comment, - italic = true, - }) - - -- ...Add or tweak any other highlight groups you need... -end - -return M \ No newline at end of file diff --git a/lua/gitea/init.lua b/lua/gitea/init.lua index 7c7853c..948b0ad 100644 --- a/lua/gitea/init.lua +++ b/lua/gitea/init.lua @@ -1,21 +1,243 @@ -local M = {} -local config = require("gitea.config") +---------------------------------------------------------------------------- +-- lua/gitea/init.lua +-- +-- Core plugin logic: reading .git/config, handling tokens, domain detection, +-- and an async request() function. Also has the top-level setup(). +---------------------------------------------------------------------------- + +local Job = require("plenary.job") local commands = require("gitea.commands") -local auth = require("gitea.auth") -local highlights = require("gitea.highlights") -function M.setup(user_opts) - -- Load user config (if any), or defaults - config.setup(user_opts or {}) +local M = {} - -- Ensure token is loaded - auth.ensure_token() +---------------------------------------------------------------------------- +-- Configuration / State +---------------------------------------------------------------------------- +M.token_folder = vim.fn.stdpath("data") .. "/gitea_tokens" +M.preview_style = "floating" +M._cached_base_url = nil +M._cached_token = nil +M._cached_owner = nil +M._cached_repo = nil - -- Register :Gitea commands - commands.register() +---------------------------------------------------------------------------- +-- Internal Utils +---------------------------------------------------------------------------- +local function ensure_dir(dirpath) + if vim.fn.isdirectory(dirpath) == 0 then + vim.fn.mkdir(dirpath, "p") + end +end - -- Apply the default Dracula-like highlights - highlights.setup() +local function read_git_config_lines() + local gitconfig_path = vim.fn.getcwd() .. "/.git/config" + local f = io.open(gitconfig_path, "r") + if not f then + return {} + end + local lines = {} + for line in f:lines() do + table.insert(lines, line) + end + f:close() + return lines +end + +local function strip_git_suffix(str) + return (str:gsub("%.git$", "")) +end + +local function parse_git_config_for_domain() + local lines = read_git_config_lines() + for _, line in ipairs(lines) do + local url = line:match("^%s*url%s*=%s*(.+)") + if url then + -- SSH style: user@host:owner/repo.git + local ssh_host = url:match("^[^@]+@([^:]+):") + if ssh_host then + return ssh_host + end + -- HTTPS style: https://host/owner/repo(.git) + local https_host = url:match("^https?://([^/]+)") + if https_host then + return https_host + end + end + end + return nil +end + +function M.parse_owner_repo() + local lines = read_git_config_lines() + for _, line in ipairs(lines) do + local url = line:match("^%s*url%s*=%s*(.+)") + if url then + -- SSH + local ssh_host, ssh_path = url:match("^[^@]+@([^:]+):(.+)") + if ssh_host and ssh_path then + ssh_path = strip_git_suffix(ssh_path) + local slash_idx = ssh_path:find("/") + if slash_idx then + return ssh_path:sub(1, slash_idx - 1), + ssh_path:sub(slash_idx + 1) + end + end + + -- HTTPS + local https_path = url:match("^https?://[^/]+/(.+)") + if https_path then + https_path = strip_git_suffix(https_path) + local slash_idx = https_path:find("/") + if slash_idx then + return https_path:sub(1, slash_idx - 1), + https_path:sub(slash_idx + 1) + end + end + end + end + return nil, nil +end + +---------------------------------------------------------------------------- +-- Token Logic +---------------------------------------------------------------------------- +local function token_file_for_domain(domain) + return M.token_folder .. "/" .. domain .. ".token" +end + +local function load_token(domain) + if not domain or domain == "" then + domain = "default" + end + local token_path = token_file_for_domain(domain) + local f = io.open(token_path, "r") + if f then + local token = f:read("*a") + f:close() + if token and token ~= "" then + return token + end + end + + vim.schedule(function() + vim.cmd(('echo "No Gitea token found for %s"'):format(domain)) + end) + + local user_input = vim.fn.inputsecret("Enter your Gitea token for " .. domain .. ": ") + if not (user_input and user_input ~= "") then + return nil + end + + ensure_dir(M.token_folder) + local fh, err = io.open(token_path, "w") + if not fh then + error(string.format("[gitea.nvim] Could not open token file for writing (%s). Error: %s", + token_path, err or "unknown")) + end + fh:write(user_input) + fh:close() + print(string.format("[gitea.nvim] Token written to %s", token_path)) + return user_input +end + +local function ensure_base_url() + if M._cached_base_url and M._cached_base_url ~= "" then + return M._cached_base_url + end + local domain = parse_git_config_for_domain() + if not domain then + error("[gitea.nvim] Could not parse a domain from .git/config.") + end + if not domain:match("^https?://") then + domain = "https://" .. domain + end + M._cached_base_url = domain + return domain +end + +local function ensure_token() + if M._cached_token and M._cached_token ~= "" then + return M._cached_token + end + local raw_domain = parse_git_config_for_domain() or "default" + raw_domain = raw_domain:gsub("^https://", "") + raw_domain = raw_domain:gsub("^http://", "") + local tok = load_token(raw_domain) + if not tok or tok == "" then + error("[gitea.nvim] Could not load/create a token for domain '" .. raw_domain .. "'.") + end + M._cached_token = tok + return tok +end + +---------------------------------------------------------------------------- +-- Async Requests (Plenary) +---------------------------------------------------------------------------- +local function do_request(method, endpoint, data, callback) + local base_url = ensure_base_url() + local token = ensure_token() + + local headers = { + "Content-Type: application/json", + "Authorization: token " .. token + } + local args = { "-s", "-X", method, base_url .. endpoint, "-H", headers[1], "-H", headers[2] } + if data then + local json_data = vim.fn.json_encode(data) + table.insert(args, "-d") + table.insert(args, json_data) + end + + Job:new({ + command = "curl", + args = args, + on_exit = function(j, return_val) + vim.schedule(function() + if return_val == 0 then + local result = table.concat(j:result(), "\n") + local ok, decoded = pcall(vim.fn.json_decode, result) + if ok then + callback(decoded, nil) + else + callback(nil, "Failed to parse JSON: " .. result) + end + else + callback(nil, "curl failed (code=" .. return_val .. ")") + end + end) + end + }):start() +end + +---------------------------------------------------------------------------- +-- Expose to other modules +---------------------------------------------------------------------------- +M.ensure_base_url = ensure_base_url +M.ensure_token = ensure_token +M.request = do_request + +---------------------------------------------------------------------------- +-- setup() +---------------------------------------------------------------------------- +function M.setup(opts) + if opts and opts.token_folder then + M.token_folder = opts.token_folder + end + if opts and opts.preview_style then + M.preview_style = opts.preview_style + end + + local domain = parse_git_config_for_domain() + if domain and not domain:match("^https?://") then + domain = "https://" .. domain + end + M._cached_base_url = domain + + local o, r = M.parse_owner_repo() + M._cached_owner = o + M._cached_repo = r + + commands.setup_commands(M) end return M \ No newline at end of file diff --git a/lua/gitea/issues.lua b/lua/gitea/issues.lua new file mode 100644 index 0000000..4dda644 --- /dev/null +++ b/lua/gitea/issues.lua @@ -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 diff --git a/lua/gitea/pulls.lua b/lua/gitea/pulls.lua new file mode 100644 index 0000000..bb9f7a1 --- /dev/null +++ b/lua/gitea/pulls.lua @@ -0,0 +1,261 @@ +---------------------------------------------------------------------------- +-- lua/gitea/pulls.lua +-- +-- Provides functions specifically for Gitea pull requests: +-- list, create, merge, comment, etc. +-- We add a key binding to merge the PR in the PR buffer. +---------------------------------------------------------------------------- + +local M = {} +local core = require('gitea') +local Job = require('plenary.job') +local lyaml = require("lyaml") + +---------------------------------------------------------------------------- +-- fallback for 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 + +---------------------------------------------------------------------------- +-- M.list_pulls +---------------------------------------------------------------------------- +function M.list_pulls(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/pulls", owner, repo) + core.request("GET", ep, nil, function(data, err) + if err then + vim.schedule(function() + vim.cmd(('echoerr "Failed to list PRs: %s"'):format(err)) + end) + return + end + if on_done then + on_done(data) + else + print(string.format("Got %d PRs for %s/%s", #data, owner, repo)) + end + end) +end + +---------------------------------------------------------------------------- +-- M.merge_pull +---------------------------------------------------------------------------- +function M.merge_pull(owner, repo, pr_number) + owner, repo = fallback_owner_repo(owner, repo) + if not owner or not repo or not pr_number then + vim.schedule(function() + vim.cmd([[echoerr "Usage: :Gitea pr merge <owner> <repo> <pr_number>"]]) + end) + return + end + local ep = string.format("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, pr_number) + local data = { Do = "merge" } + core.request("POST", ep, data, function(_, err) + if err then + vim.schedule(function() + vim.cmd(('echoerr "Failed to merge PR: %s"'):format(err)) + end) + return + end + print(("Merged PR #%d"):format(pr_number)) + end) +end + +---------------------------------------------------------------------------- +-- M.create_pull +---------------------------------------------------------------------------- +function M.create_pull(owner, repo, head, base, title, body, cb) + owner, repo = fallback_owner_repo(owner, repo) + if not owner or not repo then + if cb then cb(nil, "No owner/repo provided") end + return + end + if not head or head == "" or not base or base == "" then + if cb then cb(nil, "Head or base branch not specified") end + return + end + local ep = string.format("/api/v1/repos/%s/%s/pulls", owner, repo) + local data = { + head = head, + base = base, + title = title or ("PR from " .. head), + body = body or "" + } + core.request("POST", ep, data, function(resp, err) + if err then + if cb then cb(nil, err) end + return + end + if cb then + cb(resp, nil) + end + end) +end + +---------------------------------------------------------------------------- +-- Buffer approach for the PR (like issues) +---------------------------------------------------------------------------- +local function set_pr_buffer_options(buf) + vim.api.nvim_buf_set_option(buf, "buftype", "acwrite") -- allow saving + vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") +end + +local function attach_pr_autocmds(buf) + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + callback = function() + local owner = vim.api.nvim_buf_get_var(buf, "gitea_pr_owner") + local repo = vim.api.nvim_buf_get_var(buf, "gitea_pr_repo") + local number = vim.api.nvim_buf_get_var(buf, "gitea_pr_number") + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local doc_str = table.concat(lines, "\n") + + -- Parse as YAML + 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 PR buffer as YAML!"]]) + end) + return + end + + local pull_data = docs.pull or {} + local patch_data = {} + + if type(pull_data.title) == "string" and pull_data.title ~= "" then + patch_data.title = pull_data.title + end + if type(pull_data.body) == "string" then + patch_data.body = pull_data.body + end + + if vim.tbl_count(patch_data) > 0 then + local ep = string.format("/api/v1/repos/%s/%s/pulls/%d", owner, repo, number) + core.request("PATCH", ep, patch_data, function(_, err) + if err then + vim.schedule(function() + vim.cmd(('echoerr "Failed to update pull request: %s"'):format(err)) + end) + return + end + print("[gitea.nvim] Pull request updated.") + end) + end + end, + }) +end + +---------------------------------------------------------------------------- +-- Keymap callback to merge the current PR +---------------------------------------------------------------------------- +function M.merge_current_pr() + local buf = vim.api.nvim_get_current_buf() + local owner = vim.api.nvim_buf_get_var(buf, "gitea_pr_owner") + local repo = vim.api.nvim_buf_get_var(buf, "gitea_pr_repo") + local number = vim.api.nvim_buf_get_var(buf, "gitea_pr_number") + M.merge_pull(owner, repo, number) +end + +function M.open_pull_in_buffer(owner, repo, pr_number) + owner, repo = fallback_owner_repo(owner, repo) + if not owner or not repo or not pr_number then + vim.schedule(function() + vim.cmd([[echoerr "Usage: open_pull_in_buffer <owner> <repo> <pr_number>"]]) + end) + return + end + + -- 1) Get PR details + local pr_ep = string.format("/api/v1/repos/%s/%s/pulls/%d", owner, repo, pr_number) + core.request("GET", pr_ep, nil, function(pr, err) + if err then + vim.schedule(function() + vim.cmd(('echoerr "Failed to load PR: %s"'):format(err)) + end) + return + end + + -- 2) Create buffer, fill it + vim.schedule(function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, string.format("gitea://pr_%d.yaml", pr_number)) + vim.api.nvim_buf_set_option(buf, "filetype", "yaml") + + set_pr_buffer_options(buf) + attach_pr_autocmds(buf) + + vim.api.nvim_buf_set_var(buf, "gitea_pr_owner", owner) + vim.api.nvim_buf_set_var(buf, "gitea_pr_repo", repo) + vim.api.nvim_buf_set_var(buf, "gitea_pr_number", pr_number) + + local lines = {} + table.insert(lines, "pull:") + table.insert(lines, (" number: %d"):format(pr.number or pr_number)) + table.insert(lines, (" title: %s"):format(pr.title or "")) + table.insert(lines, (" state: %s"):format(pr.state or "")) + table.insert(lines, " body: |") + if type(pr.body) == "string" and #pr.body > 0 then + for line in string.gmatch(pr.body, "[^\r\n]+") do + table.insert(lines, " " .. line) + end + end + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + + -- Keymap to merge the current PR from the buffer + vim.api.nvim_buf_set_keymap( + buf, + "n", + "<leader>pm", + ":lua require('gitea.pulls').merge_current_pr()<CR>", + { noremap = true, silent = true } + ) + + -- Keymap to close the PR buffer quickly + vim.api.nvim_buf_set_keymap( + buf, + "n", + "<leader>pq", + ":bd!<CR>", + { noremap = true, silent = true } + ) + + -- Show the buffer + 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 + +return M diff --git a/lua/gitea/telescope.lua b/lua/gitea/telescope.lua new file mode 100644 index 0000000..bce1681 --- /dev/null +++ b/lua/gitea/telescope.lua @@ -0,0 +1,50 @@ +---------------------------------------------------------------------------- +-- lua/gitea/telescope.lua +-- +-- Minimal telescope integration for listing issues +---------------------------------------------------------------------------- + +local M = {} +local pickers = require("telescope.pickers") +local finders = require("telescope.finders") +local conf = require("telescope.config").values +local actions = require("telescope.actions") +local action_state = require("telescope.actions.state") + +local issues_mod = require("gitea.issues") + +local function open_selected_issue(prompt_bufnr) + local selection = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if not selection then return end + + local issue_obj = selection.value + local number = issue_obj.number + issues_mod.open_issue(nil, nil, number) +end + +function M.list_issues_in_telescope() + issues_mod.list_issues(nil, nil, function(data) + pickers.new({}, { + prompt_title = "Gitea Issues", + finder = finders.new_table { + results = data, + entry_maker = function(issue) + return { + value = issue, + display = ("#%d: %s"):format(issue.number, issue.title or ""), + ordinal = ("%d %s"):format(issue.number, (issue.title or "")) + } + end + }, + sorter = conf.generic_sorter({}), + attach_mappings = function(prompt_bufnr, map) + map("i", "<CR>", open_selected_issue) + map("n", "<CR>", open_selected_issue) + return true + end + }):find() + end) +end + +return M \ No newline at end of file diff --git a/plugin/gitea.vim b/plugin/gitea.vim new file mode 100644 index 0000000..7893864 --- /dev/null +++ b/plugin/gitea.vim @@ -0,0 +1,10 @@ +" Automatically load and set up gitea.nvim on startup +if exists('g:loaded_gitea_nvim') + finish +endif +let g:loaded_gitea_nvim = 1 + +lua << EOF +-- Call setup (with default or user config) so the :Gitea command is registered +require('gitea').setup() +EOF \ No newline at end of file