feat: first implementation for issues and pull requests

This commit is contained in:
2024-12-29 20:50:50 +01:00
parent dd6843091b
commit f46f60b723
13 changed files with 1248 additions and 859 deletions

View File

@@ -1,3 +1,14 @@
project_name: "gitea.nvim" project_name: "gitea.nvim"
default_prompt_blocks: default_prompt_blocks:
- "basic-prompt" - "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

View File

@@ -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

57
doc/gitea.nvim.md Normal file
View File

@@ -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 <owner> <repo>`
- `:GiteaOpenIssue <owner> <repo> <issue_number>`
- `:GiteaCreateIssue <owner> <repo> <title> <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 plugins table to `commands.setup_commands`.
## License
MIT License

View File

@@ -1,196 +1,64 @@
local config = require("gitea.config") local Job = require('plenary.job')
local auth = require("gitea.auth") local api = {}
local curl = require("plenary.curl")
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 = {
-- Helper: get base URL from config "Content-Type: application/json",
------------------------------------------------------------------------------ "Authorization: token " .. token
local function get_base_url()
return config.values.server_url
end
------------------------------------------------------------------------------
-- 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)
end
local result = curl.request({
url = url,
method = method,
headers = headers,
timeout = 10000,
body = body_data,
query = opts.query,
})
return result
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
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
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
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
end
function M.close_issue(owner, repo, number)
return M.edit_issue(owner, repo, number, { state = "closed" })
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 local curl_args = {
return vim.json.decode(result.body) "-s", -- silent mode
"-X", method, -- GET, POST, PATCH, etc.
base_url .. endpoint,
"-H", headers[1],
"-H", headers[2]
}
if data then
local json_data = vim.fn.json_encode(data)
table.insert(curl_args, "-d")
table.insert(curl_args, json_data)
end end
return nil, result and result.status
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 end
function M.close_pull_request(owner, repo, number) function api.get(base_url, endpoint, token, cb)
return M.edit_pull_request(owner, repo, number, { state = "closed" }) request("GET", base_url, endpoint, token, nil, cb)
end end
function M.reopen_pull_request(owner, repo, number) function api.post(base_url, endpoint, token, data, cb)
return M.edit_pull_request(owner, repo, number, { state = "open" }) request("POST", base_url, endpoint, token, data, cb)
end end
function M.comment_pull_request(owner, repo, number, body) function api.patch(base_url, endpoint, token, data, cb)
-- Uses same logic as comment_issue request("PATCH", base_url, endpoint, token, data, cb)
return M.comment_issue(owner, repo, number, body)
end end
return M function api.put(base_url, endpoint, token, data, cb)
request("PUT", base_url, endpoint, token, data, cb)
end
function api.delete(base_url, endpoint, token, cb)
request("DELETE", base_url, endpoint, token, nil, cb)
end
return api

View File

@@ -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

View File

@@ -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 M = {}
local config = require("gitea.config") --------------------------------------------------------------------------
local api = require("gitea.api") -- A simple completion function for the :Gitea command
local auth = require("gitea.auth") --------------------------------------------------------------------------
local highlights = require("gitea.highlights") function M._gitea_cmd_complete(arg_lead, cmd_line, cursor_pos)
local tokens = vim.split(cmd_line, "%s+")
------------------------------------------------------------------------------ -- If the user hasn't typed anything after ":Gitea", suggest top-level words
-- Debug toggle if #tokens <= 1 then
------------------------------------------------------------------------------ return { "issue", "pr" }
local DEBUG = true
------------------------------------------------------------------------------
-- Well 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
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
------------------------------------------------------------------------------
-- 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
------------------------------------------------------------------------------
-- Well store "metadata" in a table like octo.nvims 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)
end
end
local end_line = #lines
table.insert(comment_meta, { id = c.id, extmark_id = nil, start_line = start_line, end_line = end_line })
end end
-- final blank line for new comment local main_sub = tokens[2]
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 if #tokens == 2 then
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) local candidates = { "issue", "pr" }
local results = {}
-- Now create extmark for the title for _, c in ipairs(candidates) do
-- NOTE: extmark is zero-based, so set_extmark_range expects 0-based line indexes if c:find("^" .. arg_lead) then
meta.title_extmark = set_extmark_range(bufnr, title_start - 1, title_end - 1) table.insert(results, c)
-- 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
end end
meta.comments = comment_meta end
return results
-- extmark for new comment region
meta.new_comment_extmark = set_extmark_range(bufnr, new_comment_start - 1, #lines - 1)
apply_issue_highlights(bufnr)
return meta
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 end
-- 1) Gather new title -- If "issue" was selected, next subcommands can be list, create
local title_lines = get_extmark_region(bufnr, metadata.title_extmark) if main_sub == "issue" then
local new_title = table.concat(title_lines, "\n") if #tokens == 3 then
-- Trim whitespace from the title (since GitHub disallows multi-line titles) local candidates = { "list", "create" }
new_title = new_title:gsub("^%s+", ""):gsub("%s+$", ""):gsub("[\n\r]+", " ") local results = {}
for _, c in ipairs(candidates) do
-- 2) Gather new body if c:find("^" .. arg_lead) then
local body_lines = get_extmark_region(bufnr, metadata.body_extmark) table.insert(results, c)
local new_body = table.concat(body_lines, "\n") -- multiline is fine, do not trim
if DEBUG then
print("DEBUG: new_title = ", new_title)
print("DEBUG: new_body (lines) = ", vim.inspect(body_lines))
end
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
end end
return results
-- 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))
end 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)
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 {} return {}
end end
end,
-- 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
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
vim.api.nvim_create_user_command("Gitea", function(opts)
local args = vim.split(opts.args, "%s+")
local main = args[1] or ""
if main == "issue" then
local sub = args[2] or ""
if sub == "list" then
-- :Gitea issue list
telescope_mod.list_issues_in_telescope()
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)
else
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
else
print("Usage: :Gitea <issue|pr> <subcommand> [arguments...]")
print("For issues: list, create")
print("For PRs: list, create")
end
end, {
nargs = "*",
desc = "Unified Gitea command with subcommands",
complete = M._gitea_cmd_complete,
}) })
end end

View File

@@ -1,22 +1,9 @@
local Config = {} local M = {}
-- Default settings (user can override in setup{}) M.default_config = {
local defaults = { base_url = nil, -- The plugin attempts to detect from .git config or prompt if not set
config_file = ".gitea_nvim_token", -- local file containing the token token_file = vim.fn.stdpath('config') .. '/.gitea_token',
ignore_file = ".gitignore", -- file to which we can append the token file preview_style = "floating", -- or "split"
server_url = nil, -- extracted from .git remote if possible
} }
Config.values = {} return M
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

View File

@@ -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

View File

@@ -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 commands = require("gitea.commands")
local auth = require("gitea.auth")
local highlights = require("gitea.highlights")
function M.setup(user_opts) local M = {}
-- Load user config (if any), or defaults
config.setup(user_opts or {})
-- 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 local function read_git_config_lines()
highlights.setup() 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 end
return M return M

472
lua/gitea/issues.lua Normal file
View 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

261
lua/gitea/pulls.lua Normal file
View File

@@ -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

50
lua/gitea/telescope.lua Normal file
View File

@@ -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

10
plugin/gitea.vim Normal file
View File

@@ -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