From aab6026c983a128b8afc48b3c43b454c1580520a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Thu, 20 Aug 2026 13:26:22 +0200 Subject: [PATCH 1/8] feat: execute a callback on all run_job paths Previously a transport failure, an empty response body, or unparseable JSON returned without running any callback, leaving callers unable to tell that the request had finished. Now exactly one callback runs for every outcome, including curl failing to spawn. --- lua/gitlab/actions/draft_notes/init.lua | 14 ++- lua/gitlab/job.lua | 141 +++++++++++++++++------- 2 files changed, 112 insertions(+), 43 deletions(-) diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index ec2b2aec..b04a29fe 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -99,12 +99,15 @@ M.confirm_publish_all_drafts = function() u.notify(data.message, vim.log.levels.INFO) state.DRAFT_NOTES = {} require("gitlab.actions.discussions").rebuild_view(false, true) - end, function() - require("gitlab.actions.discussions").rebuild_view(false, true) + end, function(data) + if data then + u.notify(string.format("%s: %s", data.message, data.details), vim.log.levels.ERROR) + end u.notify( "Draft(s) may have been published despite the error. Check the discussion tree. Try publishing drafts individually.", vim.log.levels.WARN ) + require("gitlab.actions.discussions").rebuild_view(false, true) end) end @@ -128,9 +131,12 @@ M.confirm_publish_draft = function(tree) job.run_job("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) M.rebuild_view(unlinked) - end, function() - M.rebuild_view(unlinked) + end, function(data) + if data then + u.notify(string.format("%s: %s", data.message, data.details), vim.log.levels.ERROR) + end u.notify("Draft may have been published despite the error. Check the discussion tree.", vim.log.levels.WARN) + M.rebuild_view(unlinked) end) end diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index aaa23267..32dda1e5 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -1,15 +1,42 @@ +-- TODO: Rename this module to "client.lua" to make the purpose more obvious. -- This module is responsible for making API calls to the Go server and -- running the callbacks associated with those jobs when the JSON is returned local u = require("gitlab.utils") local M = {} ----Send a request to the Go server. +---Shape of a successful response from the Go server. Endpoints typically embed this +---alongside their own endpoint-specific fields (e.g. `discussions`, `info`), which are +---not modeled here since they vary per endpoint. +---@class SuccessResponse +---@field message string + +---Shape of an error response from the Go server (see `handleError` in `cmd/app/client.go`). +---@class ErrorResponse +---@field message string +---@field details string -- TODO: Rename to error to make it more obvious + +---Function to run on the decoded JSON response data if the response contains no error +---details. If OnSuccessCallback is omitted, the response's `message` is just notified. +---@alias OnSuccessCallback fun(data: SuccessResponse) + +---Function to run if a job fails: called with the decoded response data if it contains +---error details from the Go server, or without arguments if no usable response was +---received at all (transport failure, empty body, or invalid JSON). +---@alias OnErrorCallback fun(data: ErrorResponse?) + +---TODO: Consider renaming to something like "send_request" or "request. +---Send a request to the Go server and run callbacks on the output. +---If `callback` and `on_error_callback` are provided, exactly one of them runs for +---every request outcome: a successful response, an application-level error from the Go +---server, a transport-level failure (curl error, empty body, or invalid JSON), or +---`curl` itself failing to spawn. ---@param endpoint string The endpoint path on the server ----@param method string The HTTP rquest method ----@param callback fun(data: table) The function to run on the decoded JSON response data if the response contains no error details ----@param on_error_callback? fun(data: table) The function to run on the decoded JSON response data in case the response contains error details -M.run_job = function(endpoint, method, body, callback, on_error_callback) +---@param method string The HTTP request method +---@param body? table The request body, if required by the endpoint +---@param on_success? OnSuccessCallback +---@param on_error? OnErrorCallback +M.run_job = function(endpoint, method, body, on_success, on_error) local state = require("gitlab.state") local port = state.settings.server and state.settings.server.port local cmd = { @@ -28,55 +55,91 @@ M.run_job = function(endpoint, method, body, callback, on_error_callback) table.insert(cmd, 3, encoded_body) end - -- This handler will handle all responses from the Go server. Anything with a successful - -- status will call the callback (if it is supplied for the job). Otherwise, it will print out the - -- success message or error message and details from the Go server and run the on_error_callback - -- (if supplied for the job). - vim.system(cmd, { text = true }, function(out) + local ok, err = pcall(vim.system, cmd, { text = true }, function(out) vim.schedule(function() - if out.code ~= 0 then - u.notify(string.format("Go server exited with non-zero code: %d", out.code), vim.log.levels.ERROR) + -- Notify curl errors. Only WARN since a curl error doesn't exclude valid stdout. + if out.code ~= 0 or out.signal ~= 0 then + -- `signal` is checked too: if curl was killed by a signal rather than exiting + -- normally, `code` isn't meaningful and can read as 0. + local reason + if out.signal ~= 0 then + reason = string.format("killed by signal %d", out.signal) + else + reason = string.format( + "exited with code %d (see https://www.man7.org/linux/man-pages/man1/curl.1.html#EXIT_CODES)", + out.code + ) + end + u.notify(string.format("curl %s", reason), vim.log.levels.WARN) end - if out.stderr ~= "" then - u.notify(string.format("Could not run command `%s`! Stderr was:", table.concat(cmd, " ")), vim.log.levels.ERROR) - u.notify(vim.trim(out.stderr), vim.log.levels.ERROR) + -- `-s` suppresses curl's own warnings and errors, so non-empty stderr is coming + -- from outside curl's normal reporting path (a linked library, the dynamic + -- linker, etc.) - unusual enough to notify. + u.notify( + string.format( + "curl wrote unexpectedly to stderr while running `%s`: %s", + table.concat(cmd, " "), + vim.trim(out.stderr) + ), + vim.log.levels.WARN + ) end + -- Decode response body + ---@type (SuccessResponse|ErrorResponse)? + local data if out.stdout ~= "" then - local data_ok, data = pcall(vim.json.decode, out.stdout) - -- Failing to unmarshal JSON + local data_ok + data_ok, data = pcall(vim.json.decode, out.stdout) if not data_ok then + -- We don't notify the whole stdout here, as it could be a multi-KB HTML error + -- page or a truncated multi-KB JSON blob. If the missing information turns + -- out to be a problem, we should introduce some lua-side logging facility to + -- log the full content. local msg = string.format("Failed to parse JSON from %s endpoint", endpoint) - if type(out.stdout) == "string" then - msg = string.format(msg .. ", got: '%s'", out.stdout) + if type(data) == "string" then + msg = string.format(msg .. ", decode error: '%s'", data) end - u.notify(string.format(msg, endpoint, out.stdout), vim.log.levels.WARN) - return + data = nil + u.notify(msg, vim.log.levels.ERROR) end + end - -- If JSON provided, handle success or error cases - if data ~= nil then - if data.details == nil then - if callback then - callback(data) - return - end - local message = string.format("%s", data.message) - u.notify(message, vim.log.levels.INFO) - return - end - - -- Handle error case - local message = string.format("%s: %s", data.message, data.details) - u.notify(message, vim.log.levels.ERROR) - if on_error_callback then - on_error_callback(data) - end + -- Handle decoded response data + -- No usable response body (either curl failed to reach the server (stdout empty), + -- or the body wasn't valid JSON): + if data == nil then + if type(on_error) == "function" then + on_error() + end + return + end + -- Application-level error from the Go server: + if data.details ~= nil then + if type(on_error) == "function" then + on_error(data) + else + u.notify(string.format("%s: %s", data.message, data.details), vim.log.levels.ERROR) end + return + end + -- Successful response: + if type(on_success) == "function" then + on_success(data) + else + u.notify(string.format("%s", data.message), vim.log.levels.INFO) end end) end) + + -- Curl didn't spawn successfully + if not ok then + u.notify(string.format("Failed to spawn `%s`: %s", table.concat(cmd, " "), err), vim.log.levels.ERROR) + if type(on_error) == "function" then + on_error() + end + end end return M From 67e0008f7432890418a94ef0f24c623456d15b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Mon, 24 Aug 2026 11:18:03 +0200 Subject: [PATCH 2/8] refactor: extract anonymous on_exit from run_job The handler was a nested anonymous function, which obscured the outcome branches and could not be exercised directly. Move it to M._make_on_exit and cover the branches with specs. --- lua/gitlab/actions/draft_notes/init.lua | 2 +- lua/gitlab/job.lua | 28 ++-- tests/spec/job_spec.lua | 185 ++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 tests/spec/job_spec.lua diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index b04a29fe..581f4dbc 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -40,7 +40,7 @@ end ---Send the edits to Gitlab and refresh the draft_notes tree. ---@param note_id integer ---@param unlinked boolean ----@return function +---@return fun(text: string) M.confirm_edit_draft_note = function(note_id, unlinked) return function(text) local all_notes = List.new(state.DRAFT_NOTES) diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 32dda1e5..ced98b0a 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -55,7 +55,25 @@ M.run_job = function(endpoint, method, body, on_success, on_error) table.insert(cmd, 3, encoded_body) end - local ok, err = pcall(vim.system, cmd, { text = true }, function(out) + local ok, err = pcall(vim.system, cmd, { text = true }, M._make_on_exit(cmd, endpoint, on_success, on_error)) + -- Curl didn't spawn successfully + if not ok then + u.notify(string.format("Failed to spawn `%s`: %s", table.concat(cmd, " "), err), vim.log.levels.ERROR) + if type(on_error) == "function" then + on_error() + end + end +end + +---Return the on_exit function for the vim.system call in M.run_job. +---Exported only so tests can call it directly; not part of the public API. +---@param cmd string[] +---@param endpoint string +---@param on_success? OnSuccessCallback +---@param on_error? OnErrorCallback +---@return fun(out: vim.SystemCompleted) +M._make_on_exit = function(cmd, endpoint, on_success, on_error) + return function(out) vim.schedule(function() -- Notify curl errors. Only WARN since a curl error doesn't exclude valid stdout. if out.code ~= 0 or out.signal ~= 0 then @@ -131,14 +149,6 @@ M.run_job = function(endpoint, method, body, on_success, on_error) u.notify(string.format("%s", data.message), vim.log.levels.INFO) end end) - end) - - -- Curl didn't spawn successfully - if not ok then - u.notify(string.format("Failed to spawn `%s`: %s", table.concat(cmd, " "), err), vim.log.levels.ERROR) - if type(on_error) == "function" then - on_error() - end end end diff --git a/tests/spec/job_spec.lua b/tests/spec/job_spec.lua new file mode 100644 index 00000000..d749c962 --- /dev/null +++ b/tests/spec/job_spec.lua @@ -0,0 +1,185 @@ +describe("gitlab/job.lua", function() + describe("_make_on_exit", function() + local notifications + local job + + before_each(function() + notifications = {} + package.loaded["gitlab.utils"] = { + notify = function(msg, level) + table.insert(notifications, { msg = msg, level = level }) + end, + } + -- job.lua captures `u` via a top-level require, so stubbing gitlab.utils only + -- takes effect if job.lua is required again after the stub is in place. + package.loaded["gitlab.job"] = nil + job = require("gitlab.job") + end) + + after_each(function() + package.loaded["gitlab.utils"] = nil + package.loaded["gitlab.job"] = nil + end) + + -- vim.schedule callbacks run on the next event-loop tick, so tests need to poll + -- for a side effect instead of asserting immediately after calling the handler. + local function wait_for(condition_fn) + vim.wait(200, condition_fn, 10) + end + + local function base_out() + return { code = 0, signal = 0, stdout = "", stderr = "" } + end + + describe("curl-level notices", function() + it("does not notify when curl exits cleanly with no stderr", function() + job._make_on_exit({ "curl" }, "/ping", nil, nil)(base_out()) + -- No side effect to poll for here: just let the scheduled callback run. + vim.wait(50) + assert.are.same({}, notifications) + end) + + it("warns with the exit code when curl exits non-zero", function() + local out = base_out() + out.code = 3 + job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + wait_for(function() + return #notifications > 0 + end) + assert.are.same(1, #notifications) + assert.matches("exited with code 3", notifications[1].msg) + assert.are.same(vim.log.levels.WARN, notifications[1].level) + end) + + it("warns about the signal, not the code, when curl is killed", function() + local out = base_out() + out.code = 0 + out.signal = 9 + job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + wait_for(function() + return #notifications > 0 + end) + assert.are.same(1, #notifications) + assert.matches("killed by signal 9", notifications[1].msg) + assert.is_nil(notifications[1].msg:match("exited with code")) + end) + + it("warns with the command and trimmed stderr when curl writes to stderr", function() + local out = base_out() + out.stderr = " some linker warning\n" + job._make_on_exit({ "curl", "-s", "localhost:1234/ping" }, "/ping", nil, nil)(out) + wait_for(function() + return #notifications > 0 + end) + assert.are.same(1, #notifications) + assert.matches("curl %-s localhost:1234/ping", notifications[1].msg) + assert.matches("some linker warning", notifications[1].msg) + assert.are.same(vim.log.levels.WARN, notifications[1].level) + end) + end) + + describe("JSON decoding", function() + it("treats an empty body as no usable data without notifying a decode failure", function() + local seen_error_callback_calls = 0 + job._make_on_exit({ "curl" }, "/ping", nil, function() + seen_error_callback_calls = seen_error_callback_calls + 1 + end)(base_out()) + wait_for(function() + return seen_error_callback_calls > 0 + end) + assert.are.same(1, seen_error_callback_calls) + assert.are.same({}, notifications) + end) + + it("reports error with the endpoint and decode error when the body is invalid JSON", function() + local out = base_out() + out.stdout = "not json" + job._make_on_exit({ "curl" }, "/mr/info", nil, nil)(out) + wait_for(function() + return #notifications > 0 + end) + assert.are.same(1, #notifications) + assert.matches("Failed to parse JSON from /mr/info endpoint", notifications[1].msg) + assert.matches("decode error:", notifications[1].msg) + assert.are.same(vim.log.levels.ERROR, notifications[1].level) + end) + end) + + describe("dispatch outcomes", function() + it("calls on_error_callback with no arguments when there is no usable data", function() + local seen = "unset" + job._make_on_exit({ "curl" }, "/ping", function() + error("callback should not run") + end, function(data) + seen = data + end)(base_out()) + wait_for(function() + return seen == nil + end) + assert.is_nil(seen) + end) + + it("notifies nothing when there is no usable data and no on_error_callback", function() + job._make_on_exit({ "curl" }, "/ping", nil, nil)(base_out()) + -- No side effect to poll for here: just let the scheduled callback run. + vim.wait(50) + assert.are.same({}, notifications) + end) + + it("calls on_error_callback with the full decoded data on an application-level error", function() + local seen + local out = base_out() + out.stdout = vim.json.encode({ message = "Failed", details = "Gitlab Error" }) + job._make_on_exit({ "curl" }, "/ping", nil, function(data) + seen = data + end)(out) + wait_for(function() + return seen ~= nil + end) + assert.are.same("Failed", seen.message) + assert.are.same("Gitlab Error", seen.details) + assert.are.same({}, notifications) + end) + + it("notifies message and details on an application-level error with no on_error_callback", function() + local out = base_out() + out.stdout = vim.json.encode({ message = "Failed", details = "Gitlab Error" }) + job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + wait_for(function() + return #notifications > 0 + end) + assert.are.same(1, #notifications) + assert.are.same("Failed: Gitlab Error", notifications[1].msg) + assert.are.same(vim.log.levels.ERROR, notifications[1].level) + end) + + it("calls callback with the decoded data on success", function() + local seen + local out = base_out() + out.stdout = vim.json.encode({ message = "Done" }) + job._make_on_exit({ "curl" }, "/ping", function(data) + seen = data + end, function() + error("on_error_callback should not run") + end)(out) + wait_for(function() + return seen ~= nil + end) + assert.are.same("Done", seen.message) + assert.are.same({}, notifications) + end) + + it("notifies the message on success with no callback", function() + local out = base_out() + out.stdout = vim.json.encode({ message = "Done" }) + job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + wait_for(function() + return #notifications > 0 + end) + assert.are.same(1, #notifications) + assert.are.same("Done", notifications[1].msg) + assert.are.same(vim.log.levels.INFO, notifications[1].level) + end) + end) + end) +end) From e1521b6d1272096441d538459d67a26108eb532a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Mon, 24 Aug 2026 15:42:50 +0200 Subject: [PATCH 3/8] refactor: rename job.lua to client.lua The module sends requests to the Go server rather than managing jobs, so name it for what it does: run_job becomes send_request, and the spec follows. --- lua/gitlab/actions/approvals.lua | 6 +-- .../actions/assignees_and_reviewers.lua | 6 +-- lua/gitlab/actions/comment.lua | 14 +++---- lua/gitlab/actions/create_mr.lua | 4 +- lua/gitlab/actions/discussions/init.lua | 8 ++-- lua/gitlab/actions/draft_notes/init.lua | 10 ++--- lua/gitlab/actions/labels.lua | 6 +-- lua/gitlab/actions/merge.lua | 4 +- lua/gitlab/actions/miscellaneous.lua | 4 +- lua/gitlab/actions/pipeline.lua | 6 +-- lua/gitlab/actions/rebase.lua | 4 +- lua/gitlab/actions/summary.lua | 4 +- lua/gitlab/async.lua | 10 ++--- lua/gitlab/{job.lua => client.lua} | 14 +++---- lua/gitlab/server.lua | 12 +++--- lua/gitlab/state.lua | 4 +- tests/spec/{job_spec.lua => client_spec.lua} | 38 +++++++++---------- 17 files changed, 77 insertions(+), 77 deletions(-) rename lua/gitlab/{job.lua => client.lua} (91%) rename tests/spec/{job_spec.lua => client_spec.lua} (83%) diff --git a/lua/gitlab/actions/approvals.lua b/lua/gitlab/actions/approvals.lua index 85844b4f..c2d72dfa 100644 --- a/lua/gitlab/actions/approvals.lua +++ b/lua/gitlab/actions/approvals.lua @@ -1,4 +1,4 @@ -local job = require("gitlab.job") +local client = require("gitlab.client") local state = require("gitlab.state") local u = require("gitlab.utils") @@ -15,7 +15,7 @@ end ---Send the approval to Gitlab, notify user, and re-fresh state. M.approve = function() - job.run_job("/mr/approve", "POST", nil, function(data) + client.send_request("/mr/approve", "POST", nil, function(data) u.notify(data.message, vim.log.levels.INFO) refresh_status_state() end) @@ -23,7 +23,7 @@ end ---Send the approval revocation to Gitlab, notify user, and re-fresh state. M.revoke = function() - job.run_job("/mr/revoke", "POST", nil, function(data) + client.send_request("/mr/revoke", "POST", nil, function(data) u.notify(data.message, vim.log.levels.INFO) refresh_status_state() end) diff --git a/lua/gitlab/actions/assignees_and_reviewers.lua b/lua/gitlab/actions/assignees_and_reviewers.lua index 174561fd..95a2facd 100644 --- a/lua/gitlab/actions/assignees_and_reviewers.lua +++ b/lua/gitlab/actions/assignees_and_reviewers.lua @@ -2,7 +2,7 @@ -- and assignees in Gitlab, those who must review an MR. local u = require("gitlab.utils") -local job = require("gitlab.job") +local client = require("gitlab.client") local List = require("gitlab.utils.list") local state = require("gitlab.state") local M = {} @@ -51,7 +51,7 @@ M.add_popup = function(type) local current_ids = u.extract(current, "id") table.insert(current_ids, choice.id) local body = { ids = current_ids } - job.run_job("/mr/" .. type, "PUT", body, function(data) + client.send_request("/mr/" .. type, "PUT", body, function(data) refresh_user_state(plural, data[plural], data.message) end) end) @@ -73,7 +73,7 @@ M.delete_popup = function(type) end local ids = u.extract(M.filter_eligible(current, { choice }), "id") local body = { ids = ids } - job.run_job("/mr/" .. type, "PUT", body, function(data) + client.send_request("/mr/" .. type, "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) refresh_user_state(plural, data[plural], data.message) end) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index dc3a1e3b..f6af0abd 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -4,7 +4,7 @@ local Popup = require("nui.popup") local Layout = require("nui.layout") local state = require("gitlab.state") -local job = require("gitlab.job") +local client = require("gitlab.client") local u = require("gitlab.utils") local popup = require("gitlab.popup") local git = require("gitlab.git") @@ -50,7 +50,7 @@ local confirm_create_comment = function(text, unlinked, discussion_id) -- Creating a normal reply to a discussion if discussion_id ~= nil and not is_draft then local body = { discussion_id = discussion_id, reply = text, draft = is_draft } - job.run_job("/mr/reply", "POST", body, function() + client.send_request("/mr/reply", "POST", body, function() u.notify("Sent reply!", vim.log.levels.INFO) discussions.rebuild_view(unlinked) end) @@ -60,7 +60,7 @@ local confirm_create_comment = function(text, unlinked, discussion_id) -- Creating a draft reply, in response to a discussion ID if discussion_id ~= nil and is_draft then local body = { comment = text, discussion_id = discussion_id } - job.run_job("/mr/draft_notes/", "POST", body, function() + client.send_request("/mr/draft_notes/", "POST", body, function() u.notify("Draft reply created!", vim.log.levels.INFO) draft_notes.load_draft_notes(function() discussions.rebuild_view(unlinked) @@ -73,7 +73,7 @@ local confirm_create_comment = function(text, unlinked, discussion_id) if unlinked and discussion_id == nil then local body = { comment = text } local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" - job.run_job(endpoint, "POST", body, function() + client.send_request(endpoint, "POST", body, function() u.notify(is_draft and "Draft note created!" or "Note created!", vim.log.levels.INFO) if is_draft then draft_notes.load_draft_notes(function() @@ -101,7 +101,7 @@ local confirm_create_comment = function(text, unlinked, discussion_id) -- Creating a new comment (linked to specific changes) local body = u.merge({ type = "text", comment = text }, position_data) local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" - job.run_job(endpoint, "POST", body, function() + client.send_request(endpoint, "POST", body, function() u.notify(is_draft and "Draft comment created!" or "Comment created!", vim.log.levels.INFO) if is_draft then draft_notes.load_draft_notes(function() @@ -119,7 +119,7 @@ end ---@param unlinked boolean M.confirm_delete_comment = function(note_id, discussion_id, unlinked) local body = { discussion_id = discussion_id, note_id = tonumber(note_id) } - job.run_job("/mr/comment", "DELETE", body, function(data) + client.send_request("/mr/comment", "DELETE", body, function(data) u.notify(data.message, vim.log.levels.INFO) discussions.rebuild_view(unlinked) end) @@ -136,7 +136,7 @@ M.confirm_edit_comment = function(discussion_id, note_id, unlinked) note_id = note_id, comment = text, } - job.run_job("/mr/comment", "PATCH", body, function(data) + client.send_request("/mr/comment", "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) discussions.rebuild_view(unlinked) end) diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 39c6f1f7..0a1dcae5 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -4,7 +4,7 @@ local Layout = require("nui.layout") local Input = require("nui.input") local Popup = require("nui.popup") -local job = require("gitlab.job") +local client = require("gitlab.client") local u = require("gitlab.utils") local popup = require("gitlab.popup") local git = require("gitlab.git") @@ -321,7 +321,7 @@ M.create_mr = function() forked_project_id = forked_project_id, } - job.run_job("/create_mr", "POST", body, function(data) + client.send_request("/create_mr", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) M.reset_state() M.layout:unmount() diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 81e23247..5a551e83 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -6,7 +6,7 @@ local Split = require("nui.split") local Popup = require("nui.popup") local NuiTree = require("nui.tree") -local job = require("gitlab.job") +local client = require("gitlab.client") local u = require("gitlab.utils") local popup = require("gitlab.popup") local state = require("gitlab.state") @@ -416,7 +416,7 @@ M.toggle_discussion_resolved = function(tree) resolved = not note.resolved, } - job.run_job("/mr/discussions/resolve", "PUT", body, function(data) + client.send_request("/mr/discussions/resolve", "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) local unlinked = tree.bufnr == M.unlinked_bufnr M.rebuild_view(unlinked) @@ -439,7 +439,7 @@ M.add_emoji_to_note = function(tree, unlinked) local emojis = require("gitlab.emoji").emoji_list emoji.pick_emoji(emojis, function(name) local body = { emoji = name, note_id = note_id } - job.run_job("/mr/awardable/note/", "POST", body, function() + client.send_request("/mr/awardable/note/", "POST", body, function() u.notify("Emoji added", vim.log.levels.INFO) M.rebuild_view(unlinked) end) @@ -479,7 +479,7 @@ M.delete_emoji_from_note = function(tree, unlinked) break end end - job.run_job(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function() + client.send_request(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function() u.notify("Emoji removed", vim.log.levels.INFO) M.rebuild_view(unlinked) end) diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 581f4dbc..2097aa86 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -6,7 +6,7 @@ local common = require("gitlab.actions.common") local discussion_tree = require("gitlab.actions.discussions.tree") local git = require("gitlab.git") -local job = require("gitlab.job") +local client = require("gitlab.client") local NuiTree = require("nui.tree") local List = require("gitlab.utils.list") local u = require("gitlab.utils") @@ -48,7 +48,7 @@ M.confirm_edit_draft_note = function(note_id, unlinked) return note.id == note_id end) local body = { note = text, position = the_note.position } - job.run_job(string.format("/mr/draft_notes/%d", note_id), "PATCH", body, function(data) + client.send_request(string.format("/mr/draft_notes/%d", note_id), "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) M.rebuild_view(unlinked) end) @@ -59,7 +59,7 @@ end ---@param note_id integer ---@param unlinked boolean M.confirm_delete_draft_note = function(note_id, unlinked) - job.run_job(string.format("/mr/draft_notes/%d", note_id), "DELETE", nil, function(data) + client.send_request(string.format("/mr/draft_notes/%d", note_id), "DELETE", nil, function(data) u.notify(data.message, vim.log.levels.INFO) M.rebuild_view(unlinked) end) @@ -95,7 +95,7 @@ end ---Publish all draft notes and comments and re-render all discussion views. M.confirm_publish_all_drafts = function() local body = { publish_all = true } - job.run_job("/mr/draft_notes/publish", "POST", body, function(data) + client.send_request("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) state.DRAFT_NOTES = {} require("gitlab.actions.discussions").rebuild_view(false, true) @@ -128,7 +128,7 @@ M.confirm_publish_draft = function(tree) local note_id = note_node.is_root and root_node.id or note_node.id local body = { note = note_id } local unlinked = tree.bufnr == require("gitlab.actions.discussions").unlinked_bufnr - job.run_job("/mr/draft_notes/publish", "POST", body, function(data) + client.send_request("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) M.rebuild_view(unlinked) end, function(data) diff --git a/lua/gitlab/actions/labels.lua b/lua/gitlab/actions/labels.lua index a5e3a913..57540ea9 100644 --- a/lua/gitlab/actions/labels.lua +++ b/lua/gitlab/actions/labels.lua @@ -1,7 +1,7 @@ -- This module is responsible for the creation, deletion, -- and assignment and removeal of labels. local u = require("gitlab.utils") -local job = require("gitlab.job") +local client = require("gitlab.client") local state = require("gitlab.state") local List = require("gitlab.utils.list") @@ -44,7 +44,7 @@ M.add_popup = function(type) end table.insert(current_labels, choice) local body = { labels = current_labels } - job.run_job("/mr/" .. type, "PUT", body, function(data) + client.send_request("/mr/" .. type, "PUT", body, function(data) refresh_label_state(data.labels, data.message) end) end) @@ -60,7 +60,7 @@ M.delete_popup = function(type) end local filtered_labels = u.filter(current_labels, choice) local body = { labels = filtered_labels } - job.run_job("/mr/" .. type, "PUT", body, function(data) + client.send_request("/mr/" .. type, "PUT", body, function(data) refresh_label_state(data.labels, data.message) end) end) diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index 55e8a4cb..f4eb8ff6 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -2,7 +2,7 @@ local u = require("gitlab.utils") local popup = require("gitlab.popup") local Popup = require("nui.popup") local state = require("gitlab.state") -local job = require("gitlab.job") +local client = require("gitlab.client") local reviewer = require("gitlab.reviewer") local M = {} @@ -59,7 +59,7 @@ M.confirm_merge = function(merge_body, squash_message) merge_body.squash_message = squash_message end - job.run_job("/mr/merge", "POST", merge_body, function(data) + client.send_request("/mr/merge", "POST", merge_body, function(data) reviewer.close() u.notify(data.message, vim.log.levels.INFO) end) diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index 92bd3d9d..b0156332 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -1,6 +1,6 @@ local state = require("gitlab.state") local u = require("gitlab.utils") -local job = require("gitlab.job") +local client = require("gitlab.client") local M = {} @@ -27,7 +27,7 @@ M.attach_file = function() end local full_path = attachment_dir .. u.path_separator .. choice local body = { file_path = full_path, file_name = choice } - job.run_job("/attachment", "POST", body, function(data) + client.send_request("/attachment", "POST", body, function(data) local markdown = data.markdown vim.api.nvim_put({ markdown }, "l", true, false) end) diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index 7c7e79b5..fd0e0088 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -4,7 +4,7 @@ local Popup = require("nui.popup") local state = require("gitlab.state") -local job = require("gitlab.job") +local client = require("gitlab.client") local u = require("gitlab.utils") local popup = require("gitlab.popup") @@ -182,7 +182,7 @@ M.retrigger = function() return end if not failed_pipelines[pipeline.id] then - job.run_job("/pipeline/trigger/" .. pipeline.id, "POST", nil, function() + client.send_request("/pipeline/trigger/" .. pipeline.id, "POST", nil, function() u.notify("Pipeline " .. pipeline.id .. " re-triggered!", vim.log.levels.INFO) end) failed_pipelines[pipeline.id] = true @@ -218,7 +218,7 @@ M.see_logs = function() end local body = { job_id = j.id } - job.run_job("/job", "GET", body, function(data) + client.send_request("/job", "GET", body, function(data) local file = data.file if file == "" then u.notify("Log trace is empty", vim.log.levels.WARN) diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index 014c88d1..49670ea6 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -75,8 +75,8 @@ end local confirm_rebase = function(rebase_body) local u = require("gitlab.utils") u.notify("Rebase in progress", vim.log.levels.INFO) - local job = require("gitlab.job") - job.run_job("/mr/rebase", "POST", rebase_body, function(data) + local client = require("gitlab.client") + client.send_request("/mr/rebase", "POST", rebase_body, function(data) u.notify(data.message .. ", updating local state", vim.log.levels.INFO) local state = require("gitlab.state") require("gitlab.git_async").pull( diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index c494f124..937dcc7c 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -5,7 +5,7 @@ local Layout = require("nui.layout") local Popup = require("nui.popup") local git = require("gitlab.git") -local job = require("gitlab.job") +local client = require("gitlab.client") local common = require("gitlab.actions.common") local u = require("gitlab.utils") local popup = require("gitlab.popup") @@ -229,7 +229,7 @@ M.edit_summary = function() local description = u.get_buffer_text(M.description_bufnr) local title = u.get_buffer_text(M.title_bufnr):gsub("\n", " ") local body = { title = title, description = description } - job.run_job("/mr/summary", "PUT", body, function(data) + client.send_request("/mr/summary", "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) state.INFO.description = data.mr.description state.INFO.title = data.mr.title diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index a182b49d..0e7aeca9 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -2,7 +2,7 @@ -- an abstraction around the APIs that lets us ensure state. local server = require("gitlab.server") -local job = require("gitlab.job") +local client = require("gitlab.client") local state = require("gitlab.state") local M = {} @@ -58,11 +58,11 @@ function async:fetch(dependencies, i, args) -- Find a way to pass the right OPTS.open_reviewer option - don't open the reviewer if -- the user just wanted to see the summary, add a reviewer, or similar. local body = dependency.body and dependency.body(args) or nil - job.run_job(dependency.endpoint, dependency.method or "GET", body, function(data) + client.send_request(dependency.endpoint, dependency.method or "GET", body, function(data) state[dependency.state] = dependency.key and data[dependency.key] or data - -- TODO: Consider if this cannot be called outside of the run_job callback to fetch - -- the dependencies in parallel rather than in sequence and run self.cb in this - -- callback instead of self:fetch when the last dependency has been fetched. + -- TODO: Consider if this cannot be called outside of the send_request callback to + -- fetch the dependencies in parallel rather than in sequence and run self.cb in + -- this callback instead of self:fetch when the last dependency has been fetched. self:fetch(dependencies, i + 1, args) end) end diff --git a/lua/gitlab/job.lua b/lua/gitlab/client.lua similarity index 91% rename from lua/gitlab/job.lua rename to lua/gitlab/client.lua index ced98b0a..a1261706 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/client.lua @@ -1,4 +1,3 @@ --- TODO: Rename this module to "client.lua" to make the purpose more obvious. -- This module is responsible for making API calls to the Go server and -- running the callbacks associated with those jobs when the JSON is returned local u = require("gitlab.utils") @@ -20,12 +19,13 @@ local M = {} ---details. If OnSuccessCallback is omitted, the response's `message` is just notified. ---@alias OnSuccessCallback fun(data: SuccessResponse) ----Function to run if a job fails: called with the decoded response data if it contains ----error details from the Go server, or without arguments if no usable response was ----received at all (transport failure, empty body, or invalid JSON). +---Function to run if a request fails: called with the decoded response data if it +---contains error details from the Go server, or without arguments if no usable response +---was received at all (transport failure, empty body, or invalid JSON). +---If OnErrorCallback is omitted, the response's `message` and `error` are just +---notified. ---@alias OnErrorCallback fun(data: ErrorResponse?) ----TODO: Consider renaming to something like "send_request" or "request. ---Send a request to the Go server and run callbacks on the output. ---If `callback` and `on_error_callback` are provided, exactly one of them runs for ---every request outcome: a successful response, an application-level error from the Go @@ -36,7 +36,7 @@ local M = {} ---@param body? table The request body, if required by the endpoint ---@param on_success? OnSuccessCallback ---@param on_error? OnErrorCallback -M.run_job = function(endpoint, method, body, on_success, on_error) +M.send_request = function(endpoint, method, body, on_success, on_error) local state = require("gitlab.state") local port = state.settings.server and state.settings.server.port local cmd = { @@ -65,7 +65,7 @@ M.run_job = function(endpoint, method, body, on_success, on_error) end end ----Return the on_exit function for the vim.system call in M.run_job. +---Return the on_exit function for the vim.system call in M.send_request. ---Exported only so tests can call it directly; not part of the public API. ---@param cmd string[] ---@param endpoint string diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 813cbc61..70b3fd87 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -3,7 +3,7 @@ -- to Gitlab and returning the data local state = require("gitlab.state") local u = require("gitlab.utils") -local job = require("gitlab.job") +local client = require("gitlab.client") local version = require("gitlab.version") local M = {} @@ -174,7 +174,7 @@ M.shutdown = function(cb) vim.notify("The gitlab.nvim server is not running", vim.log.levels.ERROR) return end - job.run_job("/shutdown", "POST", { restart = false }, function(data) + client.send_request("/shutdown", "POST", { restart = false }, function(data) state.go_server_running = false state.clear_data() if cb then @@ -191,7 +191,7 @@ M.restart = function(cb) vim.notify("The gitlab.nvim server is not running", vim.log.levels.ERROR) return end - job.run_job("/shutdown", "POST", { restart = true }, function(data) + client.send_request("/shutdown", "POST", { restart = true }, function(data) state.go_server_running = false M.start(function() state.clear_data() @@ -215,9 +215,9 @@ M.get_version = function(callback) local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait() local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown" - -- We call the "/version" endpoint here instead of through the regular run_job pattern because - -- earlier versions of the plugin may not have the endpoint. We handle a 404 as an "unknown" - -- version error. + -- We call the "/version" endpoint here instead of through the regular send_request + -- pattern because earlier versions of the plugin may not have the endpoint. We handle + -- a 404 as an "unknown" version error. local cmd = { "curl", "--noproxy", diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index f264abb9..2635fd0a 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -638,9 +638,9 @@ M.dependencies = { ---@param dep string The dependency name to re-load ---@param cb fun(data) The function to call with the dependency data M.load_new_state = function(dep, cb) - local job = require("gitlab.job") + local client = require("gitlab.client") local dependency = M.dependencies[dep] - job.run_job( + client.send_request( dependency.endpoint, dependency.method or "GET", dependency.body and dependency.body() or nil, diff --git a/tests/spec/job_spec.lua b/tests/spec/client_spec.lua similarity index 83% rename from tests/spec/job_spec.lua rename to tests/spec/client_spec.lua index d749c962..e305761c 100644 --- a/tests/spec/job_spec.lua +++ b/tests/spec/client_spec.lua @@ -1,7 +1,7 @@ -describe("gitlab/job.lua", function() +describe("gitlab/client.lua", function() describe("_make_on_exit", function() local notifications - local job + local client before_each(function() notifications = {} @@ -10,15 +10,15 @@ describe("gitlab/job.lua", function() table.insert(notifications, { msg = msg, level = level }) end, } - -- job.lua captures `u` via a top-level require, so stubbing gitlab.utils only - -- takes effect if job.lua is required again after the stub is in place. - package.loaded["gitlab.job"] = nil - job = require("gitlab.job") + -- client.lua captures `u` via a top-level require, so stubbing gitlab.utils only + -- takes effect if client.lua is required again after the stub is in place. + package.loaded["gitlab.client"] = nil + client = require("gitlab.client") end) after_each(function() package.loaded["gitlab.utils"] = nil - package.loaded["gitlab.job"] = nil + package.loaded["gitlab.client"] = nil end) -- vim.schedule callbacks run on the next event-loop tick, so tests need to poll @@ -33,7 +33,7 @@ describe("gitlab/job.lua", function() describe("curl-level notices", function() it("does not notify when curl exits cleanly with no stderr", function() - job._make_on_exit({ "curl" }, "/ping", nil, nil)(base_out()) + client._make_on_exit({ "curl" }, "/ping", nil, nil)(base_out()) -- No side effect to poll for here: just let the scheduled callback run. vim.wait(50) assert.are.same({}, notifications) @@ -42,7 +42,7 @@ describe("gitlab/job.lua", function() it("warns with the exit code when curl exits non-zero", function() local out = base_out() out.code = 3 - job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + client._make_on_exit({ "curl" }, "/ping", nil, nil)(out) wait_for(function() return #notifications > 0 end) @@ -55,7 +55,7 @@ describe("gitlab/job.lua", function() local out = base_out() out.code = 0 out.signal = 9 - job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + client._make_on_exit({ "curl" }, "/ping", nil, nil)(out) wait_for(function() return #notifications > 0 end) @@ -67,7 +67,7 @@ describe("gitlab/job.lua", function() it("warns with the command and trimmed stderr when curl writes to stderr", function() local out = base_out() out.stderr = " some linker warning\n" - job._make_on_exit({ "curl", "-s", "localhost:1234/ping" }, "/ping", nil, nil)(out) + client._make_on_exit({ "curl", "-s", "localhost:1234/ping" }, "/ping", nil, nil)(out) wait_for(function() return #notifications > 0 end) @@ -81,7 +81,7 @@ describe("gitlab/job.lua", function() describe("JSON decoding", function() it("treats an empty body as no usable data without notifying a decode failure", function() local seen_error_callback_calls = 0 - job._make_on_exit({ "curl" }, "/ping", nil, function() + client._make_on_exit({ "curl" }, "/ping", nil, function() seen_error_callback_calls = seen_error_callback_calls + 1 end)(base_out()) wait_for(function() @@ -94,7 +94,7 @@ describe("gitlab/job.lua", function() it("reports error with the endpoint and decode error when the body is invalid JSON", function() local out = base_out() out.stdout = "not json" - job._make_on_exit({ "curl" }, "/mr/info", nil, nil)(out) + client._make_on_exit({ "curl" }, "/mr/info", nil, nil)(out) wait_for(function() return #notifications > 0 end) @@ -108,7 +108,7 @@ describe("gitlab/job.lua", function() describe("dispatch outcomes", function() it("calls on_error_callback with no arguments when there is no usable data", function() local seen = "unset" - job._make_on_exit({ "curl" }, "/ping", function() + client._make_on_exit({ "curl" }, "/ping", function() error("callback should not run") end, function(data) seen = data @@ -120,7 +120,7 @@ describe("gitlab/job.lua", function() end) it("notifies nothing when there is no usable data and no on_error_callback", function() - job._make_on_exit({ "curl" }, "/ping", nil, nil)(base_out()) + client._make_on_exit({ "curl" }, "/ping", nil, nil)(base_out()) -- No side effect to poll for here: just let the scheduled callback run. vim.wait(50) assert.are.same({}, notifications) @@ -130,7 +130,7 @@ describe("gitlab/job.lua", function() local seen local out = base_out() out.stdout = vim.json.encode({ message = "Failed", details = "Gitlab Error" }) - job._make_on_exit({ "curl" }, "/ping", nil, function(data) + client._make_on_exit({ "curl" }, "/ping", nil, function(data) seen = data end)(out) wait_for(function() @@ -144,7 +144,7 @@ describe("gitlab/job.lua", function() it("notifies message and details on an application-level error with no on_error_callback", function() local out = base_out() out.stdout = vim.json.encode({ message = "Failed", details = "Gitlab Error" }) - job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + client._make_on_exit({ "curl" }, "/ping", nil, nil)(out) wait_for(function() return #notifications > 0 end) @@ -157,7 +157,7 @@ describe("gitlab/job.lua", function() local seen local out = base_out() out.stdout = vim.json.encode({ message = "Done" }) - job._make_on_exit({ "curl" }, "/ping", function(data) + client._make_on_exit({ "curl" }, "/ping", function(data) seen = data end, function() error("on_error_callback should not run") @@ -172,7 +172,7 @@ describe("gitlab/job.lua", function() it("notifies the message on success with no callback", function() local out = base_out() out.stdout = vim.json.encode({ message = "Done" }) - job._make_on_exit({ "curl" }, "/ping", nil, nil)(out) + client._make_on_exit({ "curl" }, "/ping", nil, nil)(out) wait_for(function() return #notifications > 0 end) From 5a0df42abc09ea9f274049aefc4b4f066cafd0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Sat, 29 Aug 2026 07:32:47 +0200 Subject: [PATCH 4/8] fix: redact request body from notification Redact the request body from the notifications that echo the curl command, since it can carry comment and MR text. --- lua/gitlab/client.lua | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/lua/gitlab/client.lua b/lua/gitlab/client.lua index a1261706..c3b1f52b 100644 --- a/lua/gitlab/client.lua +++ b/lua/gitlab/client.lua @@ -10,7 +10,8 @@ local M = {} ---@class SuccessResponse ---@field message string ----Shape of an error response from the Go server (see `handleError` in `cmd/app/client.go`). +---Shape of an error response from the Go server (see `handleError` in +---`cmd/app/client.go`). ---@class ErrorResponse ---@field message string ---@field details string -- TODO: Rename to error to make it more obvious @@ -55,10 +56,19 @@ M.send_request = function(endpoint, method, body, on_success, on_error) table.insert(cmd, 3, encoded_body) end - local ok, err = pcall(vim.system, cmd, { text = true }, M._make_on_exit(cmd, endpoint, on_success, on_error)) + -- The body can carry comment or MR text, so it must never reach a user facing + -- message. Both the spawn failure below and the stderr notice in `_make_on_exit` + -- report this redacted copy rather than `cmd` itself. + local display_cmd = cmd + if body ~= nil then + display_cmd = vim.deepcopy(cmd) + display_cmd[3] = "REDACTED" + end + + local ok, err = pcall(vim.system, cmd, { text = true }, M._make_on_exit(display_cmd, endpoint, on_success, on_error)) -- Curl didn't spawn successfully if not ok then - u.notify(string.format("Failed to spawn `%s`: %s", table.concat(cmd, " "), err), vim.log.levels.ERROR) + u.notify(string.format("Failed to spawn `%s`: %s", table.concat(display_cmd, " "), err), vim.log.levels.ERROR) if type(on_error) == "function" then on_error() end @@ -67,12 +77,12 @@ end ---Return the on_exit function for the vim.system call in M.send_request. ---Exported only so tests can call it directly; not part of the public API. ----@param cmd string[] +---@param display_cmd string[] The command, with any request body redacted ---@param endpoint string ---@param on_success? OnSuccessCallback ---@param on_error? OnErrorCallback ---@return fun(out: vim.SystemCompleted) -M._make_on_exit = function(cmd, endpoint, on_success, on_error) +M._make_on_exit = function(display_cmd, endpoint, on_success, on_error) return function(out) vim.schedule(function() -- Notify curl errors. Only WARN since a curl error doesn't exclude valid stdout. @@ -97,7 +107,7 @@ M._make_on_exit = function(cmd, endpoint, on_success, on_error) u.notify( string.format( "curl wrote unexpectedly to stderr while running `%s`: %s", - table.concat(cmd, " "), + table.concat(display_cmd, " "), vim.trim(out.stderr) ), vim.log.levels.WARN From 57106076ca8de795fdcb61a6b3f83fdf67eb3c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Sat, 29 Aug 2026 07:35:59 +0200 Subject: [PATCH 5/8] fix: reject response bodies that are not objects --- lua/gitlab/client.lua | 13 +++++++++---- tests/spec/client_spec.lua | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lua/gitlab/client.lua b/lua/gitlab/client.lua index c3b1f52b..6dea062b 100644 --- a/lua/gitlab/client.lua +++ b/lua/gitlab/client.lua @@ -120,13 +120,18 @@ M._make_on_exit = function(display_cmd, endpoint, on_success, on_error) if out.stdout ~= "" then local data_ok data_ok, data = pcall(vim.json.decode, out.stdout) - if not data_ok then + -- A body that decodes to anything but a table is as unusable as one that doesn't + -- decode at all: `null` yields `vim.NIL`, and a bare number or string yields a + -- Lua number or string, none of which can carry `message`/`error`. + if not data_ok or type(data) ~= "table" then -- We don't notify the whole stdout here, as it could be a multi-KB HTML error -- page or a truncated multi-KB JSON blob. If the missing information turns -- out to be a problem, we should introduce some lua-side logging facility to -- log the full content. local msg = string.format("Failed to parse JSON from %s endpoint", endpoint) - if type(data) == "string" then + -- On a decode failure `data` is pcall's error message; on a successful decode + -- of a JSON string it's the payload, which is not a decode error. + if not data_ok and type(data) == "string" then msg = string.format(msg .. ", decode error: '%s'", data) end data = nil @@ -135,8 +140,8 @@ M._make_on_exit = function(display_cmd, endpoint, on_success, on_error) end -- Handle decoded response data - -- No usable response body (either curl failed to reach the server (stdout empty), - -- or the body wasn't valid JSON): + -- No usable response body (curl failed to reach the server (stdout empty), the + -- body wasn't valid JSON, or it wasn't a JSON object): if data == nil then if type(on_error) == "function" then on_error() diff --git a/tests/spec/client_spec.lua b/tests/spec/client_spec.lua index e305761c..e161512c 100644 --- a/tests/spec/client_spec.lua +++ b/tests/spec/client_spec.lua @@ -103,6 +103,30 @@ describe("gitlab/client.lua", function() assert.matches("decode error:", notifications[1].msg) assert.are.same(vim.log.levels.ERROR, notifications[1].level) end) + + -- These bodies are structurally valid JSON, so `vim.json.decode` succeeds, but + -- they are not objects: `null` decodes to `vim.NIL`, and a bare number or string + -- decodes to a Lua number or string. Indexing those for `error` either raises or + -- silently yields nil, so they have to be rejected alongside a decode failure. + it("treats a structurally valid but non-object body as no usable data", function() + for _, body in ipairs({ "null", "123", '"just a string"' }) do + notifications = {} + local seen = "unset" + local out = base_out() + out.stdout = body + client._make_on_exit({ "curl" }, "/mr/info", function() + error("on_success should not run for body " .. body) + end, function(data) + seen = data + end)(out) + wait_for(function() + return seen == nil + end) + assert.is_nil(seen, "expected on_error(nil) for body " .. body) + assert.are.same(1, #notifications, "expected one notification for body " .. body) + assert.matches("Failed to parse JSON from /mr/info endpoint", notifications[1].msg) + end + end) end) describe("dispatch outcomes", function() From 53b9dfb599a6017736e4980b383a55a174dcb539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Mon, 24 Aug 2026 15:49:02 +0200 Subject: [PATCH 6/8] refactor: rename details to error in ErrorResponse "error" states what the field holds, which reads better on the Lua side where it gates the on_error callback. This changes the wire format, so a response carrying the old key is still accepted, with a warning to rebuild, for users who supply their own binary via server.binary_provided. --- cmd/app/client.go | 2 +- cmd/app/create_mr_test.go | 4 ++-- cmd/app/draft_notes_test.go | 2 +- cmd/app/list_discussions_test.go | 2 +- cmd/app/merge_requests_by_username_test.go | 10 ++++---- cmd/app/mergeability_checks_test.go | 2 +- cmd/app/middleware_test.go | 14 +++++------ cmd/app/resolve_discussion_test.go | 4 ++-- cmd/app/response_types.go | 2 +- cmd/app/test_helpers.go | 4 ++-- lua/gitlab/actions/draft_notes/init.lua | 4 ++-- lua/gitlab/async.lua | 4 ++-- lua/gitlab/client.lua | 27 ++++++++++++++++------ tests/spec/client_spec.lua | 20 ++++++++-------- 14 files changed, 57 insertions(+), 44 deletions(-) diff --git a/cmd/app/client.go b/cmd/app/client.go index 0663d2d0..b3a2c930 100644 --- a/cmd/app/client.go +++ b/cmd/app/client.go @@ -131,7 +131,7 @@ func handleError(w http.ResponseWriter, err error, message string, status int) { w.WriteHeader(status) response := ErrorResponse{ Message: message, - Details: err.Error(), + Error: err.Error(), } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/create_mr_test.go b/cmd/app/create_mr_test.go index a52db91f..b349ddcc 100644 --- a/cmd/app/create_mr_test.go +++ b/cmd/app/create_mr_test.go @@ -71,7 +71,7 @@ func TestCreateMr(t *testing.T) { ) data, _ := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "Title is required") + assert(t, data.Error, "Title is required") }) t.Run("Handles missing target branch", func(t *testing.T) { @@ -85,6 +85,6 @@ func TestCreateMr(t *testing.T) { ) data, _ := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "TargetBranch is required") + assert(t, data.Error, "TargetBranch is required") }) } diff --git a/cmd/app/draft_notes_test.go b/cmd/app/draft_notes_test.go index f92f1570..76c7bc62 100644 --- a/cmd/app/draft_notes_test.go +++ b/cmd/app/draft_notes_test.go @@ -184,7 +184,7 @@ func TestEditDraftNote(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "Note is required") + assert(t, data.Error, "Note is required") assert(t, status, http.StatusBadRequest) }) } diff --git a/cmd/app/list_discussions_test.go b/cmd/app/list_discussions_test.go index ab142a4c..3ffbe296 100644 --- a/cmd/app/list_discussions_test.go +++ b/cmd/app/list_discussions_test.go @@ -137,6 +137,6 @@ func TestListDiscussions(t *testing.T) { ) data, _ := getFailData(t, svc, request) assert(t, data.Message, "Could not fetch emojis") - assert(t, data.Details, "Some error from emoji service") + assert(t, data.Error, "Some error from emoji service") }) } diff --git a/cmd/app/merge_requests_by_username_test.go b/cmd/app/merge_requests_by_username_test.go index b324d92c..2d775ad4 100644 --- a/cmd/app/merge_requests_by_username_test.go +++ b/cmd/app/merge_requests_by_username_test.go @@ -48,7 +48,7 @@ func TestListMergeRequestByUsername(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "No MRs found") - assert(t, data.Details, "hcramer did not have any MRs") + assert(t, data.Error, "hcramer did not have any MRs") assert(t, status, http.StatusNotFound) }) @@ -63,7 +63,7 @@ func TestListMergeRequestByUsername(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "Username is required") + assert(t, data.Error, "Username is required") assert(t, status, http.StatusBadRequest) }) @@ -78,7 +78,7 @@ func TestListMergeRequestByUsername(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "UserId is required") + assert(t, data.Error, "UserId is required") assert(t, status, http.StatusBadRequest) }) @@ -91,7 +91,7 @@ func TestListMergeRequestByUsername(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "An error occurred") - assert(t, data.Details, strings.Repeat("some error from Gitlab; ", 3)) + assert(t, data.Error, strings.Repeat("some error from Gitlab; ", 3)) assert(t, status, http.StatusInternalServerError) }) @@ -104,7 +104,7 @@ func TestListMergeRequestByUsername(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "An error occurred") - assert(t, data.Details, strings.Repeat("An error occurred on the /merge_requests_by_username endpoint; ", 3)) + assert(t, data.Error, strings.Repeat("An error occurred on the /merge_requests_by_username endpoint; ", 3)) assert(t, status, http.StatusInternalServerError) }) } diff --git a/cmd/app/mergeability_checks_test.go b/cmd/app/mergeability_checks_test.go index 45ccef55..bb60b6c4 100644 --- a/cmd/app/mergeability_checks_test.go +++ b/cmd/app/mergeability_checks_test.go @@ -116,6 +116,6 @@ func TestMergeabilityChecksHandler(t *testing.T) { ) data, _ := getFailData(t, svc, request) assert(t, data.Message, "Could not get mergeability checks") - assert(t, data.Details, "failed to fetch mergeability checks: "+errorFromGitlab.Error()) + assert(t, data.Error, "failed to fetch mergeability checks: "+errorFromGitlab.Error()) }) } diff --git a/cmd/app/middleware_test.go b/cmd/app/middleware_test.go index 853f8afe..59cc23bc 100644 --- a/cmd/app/middleware_test.go +++ b/cmd/app/middleware_test.go @@ -29,7 +29,7 @@ func TestMethodMiddleware(t *testing.T) { handler := middleware(fakeHandler{}, mw) data, status := getFailData(t, handler, request) assert(t, data.Message, "Invalid request type") - assert(t, data.Details, "Expected: POST") + assert(t, data.Error, "Expected: POST") assert(t, status, http.StatusMethodNotAllowed) }) t.Run("Fails bad method with multiple", func(t *testing.T) { @@ -38,7 +38,7 @@ func TestMethodMiddleware(t *testing.T) { handler := middleware(fakeHandler{}, mw) data, status := getFailData(t, handler, request) assert(t, data.Message, "Invalid request type") - assert(t, data.Details, "Expected: POST; PATCH") + assert(t, data.Error, "Expected: POST; PATCH") assert(t, status, http.StatusMethodNotAllowed) }) t.Run("Allows ok method through", func(t *testing.T) { @@ -75,7 +75,7 @@ func TestWithMrMiddleware(t *testing.T) { data, status := getFailData(t, handler, request) assert(t, status, http.StatusNotFound) assert(t, data.Message, "No MRs Found") - assert(t, data.Details, "branch 'foo' does not have any merge requests") + assert(t, data.Error, "branch 'foo' does not have any merge requests") }) t.Run("Handles when there are too many MRs", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/foo", nil) @@ -88,7 +88,7 @@ func TestWithMrMiddleware(t *testing.T) { data, status := getFailData(t, handler, request) assert(t, status, http.StatusBadRequest) assert(t, data.Message, "Multiple MRs found") - assert(t, data.Details, "please call gitlab.choose_merge_request()") + assert(t, data.Error, "please call gitlab.choose_merge_request()") }) } @@ -100,7 +100,7 @@ func TestValidatorMiddleware(t *testing.T) { withPayloadValidation(methodToPayload{http.MethodPost: newPayload[FakePayload]}), ), request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "Foo is required") + assert(t, data.Error, "Foo is required") assert(t, status, http.StatusBadRequest) }) t.Run("Should allow valid payload through", func(t *testing.T) { @@ -128,7 +128,7 @@ func TestValidatorMiddleware(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "Start is required; End is required") + assert(t, data.Error, "Start is required; End is required") assert(t, status, http.StatusBadRequest) }) t.Run("Should reject a missing line_range when FileName is set", func(t *testing.T) { @@ -148,7 +148,7 @@ func TestValidatorMiddleware(t *testing.T) { ) data, status := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "The field 'LineRange' failed on validation on the 'required_with' tag") + assert(t, data.Error, "The field 'LineRange' failed on validation on the 'required_with' tag") assert(t, status, http.StatusBadRequest) }) t.Run("Should allow a missing line_range when there is no FileName (unlinked comment)", func(t *testing.T) { diff --git a/cmd/app/resolve_discussion_test.go b/cmd/app/resolve_discussion_test.go index e4912fac..df5fed96 100644 --- a/cmd/app/resolve_discussion_test.go +++ b/cmd/app/resolve_discussion_test.go @@ -64,7 +64,7 @@ func TestResolveDiscussion(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", payload) data, status := getFailData(t, svc, request) assert(t, data.Message, "Invalid payload") - assert(t, data.Details, "DiscussionID is required") + assert(t, data.Error, "DiscussionID is required") assert(t, status, http.StatusBadRequest) }) @@ -78,7 +78,7 @@ func TestResolveDiscussion(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", testResolveMergeRequestPayload) data, status := getFailData(t, svc, request) assert(t, data.Message, "Could not resolve discussion") - assert(t, data.Details, "some error from Gitlab") + assert(t, data.Error, "some error from Gitlab") assert(t, status, http.StatusInternalServerError) }) } diff --git a/cmd/app/response_types.go b/cmd/app/response_types.go index c34fa0a2..c7a91a67 100644 --- a/cmd/app/response_types.go +++ b/cmd/app/response_types.go @@ -6,7 +6,7 @@ import ( type ErrorResponse struct { Message string `json:"message"` - Details string `json:"details"` + Error string `json:"error"` } type SuccessResponse struct { diff --git a/cmd/app/test_helpers.go b/cmd/app/test_helpers.go index 2839f77b..e88445f7 100644 --- a/cmd/app/test_helpers.go +++ b/cmd/app/test_helpers.go @@ -105,13 +105,13 @@ func (f *testBase) handleGitlabError() (*gitlab.Response, error) { func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) { t.Helper() assert(t, data.Message, msg) - assert(t, data.Details, errorFromGitlab.Error()) + assert(t, data.Error, errorFromGitlab.Error()) } func checkNon200(t *testing.T, data ErrorResponse, msg, endpoint string) { t.Helper() assert(t, data.Message, msg) - assert(t, data.Details, fmt.Sprintf("An error occurred on the %s endpoint", endpoint)) + assert(t, data.Error, fmt.Sprintf("An error occurred on the %s endpoint", endpoint)) } type FakeGitManager struct { diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 2097aa86..02a9ab0c 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -101,7 +101,7 @@ M.confirm_publish_all_drafts = function() require("gitlab.actions.discussions").rebuild_view(false, true) end, function(data) if data then - u.notify(string.format("%s: %s", data.message, data.details), vim.log.levels.ERROR) + u.notify(string.format("%s: %s", data.message, data.error), vim.log.levels.ERROR) end u.notify( "Draft(s) may have been published despite the error. Check the discussion tree. Try publishing drafts individually.", @@ -133,7 +133,7 @@ M.confirm_publish_draft = function(tree) M.rebuild_view(unlinked) end, function(data) if data then - u.notify(string.format("%s: %s", data.message, data.details), vim.log.levels.ERROR) + u.notify(string.format("%s: %s", data.message, data.error), vim.log.levels.ERROR) end u.notify("Draft may have been published despite the error. Check the discussion tree.", vim.log.levels.WARN) M.rebuild_view(unlinked) diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index 0e7aeca9..5cef1a68 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -49,9 +49,9 @@ function async:fetch(dependencies, i, args) end -- Call the API, set the data, and then call the next API - -- TODO: Add a on_error_callback that will call choose_merge_request for the user: + -- TODO: Add a on_error callback that will call choose_merge_request for the user: -- function(data) - -- if data.details and data.details:match("call gitlab.choose_merge_request") then + -- if data.error and data.error:match("call gitlab.choose_merge_request") then -- require("gitlab").choose_merge_request(OPTS) -- end -- end diff --git a/lua/gitlab/client.lua b/lua/gitlab/client.lua index 6dea062b..f9ddb753 100644 --- a/lua/gitlab/client.lua +++ b/lua/gitlab/client.lua @@ -14,21 +14,21 @@ local M = {} ---`cmd/app/client.go`). ---@class ErrorResponse ---@field message string ----@field details string -- TODO: Rename to error to make it more obvious +---@field error string ----Function to run on the decoded JSON response data if the response contains no error ----details. If OnSuccessCallback is omitted, the response's `message` is just notified. +---Function to run on the decoded JSON response data if the response contains no error. +---If OnSuccessCallback is omitted, the response's `message` is just notified. ---@alias OnSuccessCallback fun(data: SuccessResponse) ---Function to run if a request fails: called with the decoded response data if it ----contains error details from the Go server, or without arguments if no usable response +---contains an error from the Go server, or without arguments if no usable response ---was received at all (transport failure, empty body, or invalid JSON). ---If OnErrorCallback is omitted, the response's `message` and `error` are just ---notified. ---@alias OnErrorCallback fun(data: ErrorResponse?) ---Send a request to the Go server and run callbacks on the output. ----If `callback` and `on_error_callback` are provided, exactly one of them runs for +---If `on_success` and `on_error` callbacks are provided, exactly one of them runs for ---every request outcome: a successful response, an application-level error from the Go ---server, a transport-level failure (curl error, empty body, or invalid JSON), or ---`curl` itself failing to spawn. @@ -148,12 +148,18 @@ M._make_on_exit = function(display_cmd, endpoint, on_success, on_error) end return end + -- TODO: data.details is checked to prevent breaking for binary_provided users. + -- Remove in the future. + if data.details ~= nil and data.error == nil then + u.notify("Go server returned outdated response format. Rebuild the Go server.", vim.log.levels.WARN) + data.error = data.details + end -- Application-level error from the Go server: - if data.details ~= nil then + if data.error ~= nil then if type(on_error) == "function" then on_error(data) else - u.notify(string.format("%s: %s", data.message, data.details), vim.log.levels.ERROR) + M.notify_error(data) end return end @@ -167,4 +173,11 @@ M._make_on_exit = function(display_cmd, endpoint, on_success, on_error) end end +---@param data? ErrorResponse +M.notify_error = function(data) + if data then + u.notify(string.format("%s: %s", data.message, data.error), vim.log.levels.ERROR) + end +end + return M diff --git a/tests/spec/client_spec.lua b/tests/spec/client_spec.lua index e161512c..5b4f6e54 100644 --- a/tests/spec/client_spec.lua +++ b/tests/spec/client_spec.lua @@ -130,10 +130,10 @@ describe("gitlab/client.lua", function() end) describe("dispatch outcomes", function() - it("calls on_error_callback with no arguments when there is no usable data", function() + it("calls on_error callback with no arguments when there is no usable data", function() local seen = "unset" client._make_on_exit({ "curl" }, "/ping", function() - error("callback should not run") + error("on_success should not run") end, function(data) seen = data end)(base_out()) @@ -143,17 +143,17 @@ describe("gitlab/client.lua", function() assert.is_nil(seen) end) - it("notifies nothing when there is no usable data and no on_error_callback", function() + it("notifies nothing when there is no usable data and no on_error callback", function() client._make_on_exit({ "curl" }, "/ping", nil, nil)(base_out()) -- No side effect to poll for here: just let the scheduled callback run. vim.wait(50) assert.are.same({}, notifications) end) - it("calls on_error_callback with the full decoded data on an application-level error", function() + it("calls on_error callback with the full decoded data on an application-level error", function() local seen local out = base_out() - out.stdout = vim.json.encode({ message = "Failed", details = "Gitlab Error" }) + out.stdout = vim.json.encode({ message = "Failed", error = "Gitlab Error" }) client._make_on_exit({ "curl" }, "/ping", nil, function(data) seen = data end)(out) @@ -161,13 +161,13 @@ describe("gitlab/client.lua", function() return seen ~= nil end) assert.are.same("Failed", seen.message) - assert.are.same("Gitlab Error", seen.details) + assert.are.same("Gitlab Error", seen.error) assert.are.same({}, notifications) end) - it("notifies message and details on an application-level error with no on_error_callback", function() + it("notifies message and error on an application-level error with no on_error callback", function() local out = base_out() - out.stdout = vim.json.encode({ message = "Failed", details = "Gitlab Error" }) + out.stdout = vim.json.encode({ message = "Failed", error = "Gitlab Error" }) client._make_on_exit({ "curl" }, "/ping", nil, nil)(out) wait_for(function() return #notifications > 0 @@ -177,14 +177,14 @@ describe("gitlab/client.lua", function() assert.are.same(vim.log.levels.ERROR, notifications[1].level) end) - it("calls callback with the decoded data on success", function() + it("calls on_success callback with the decoded data on success", function() local seen local out = base_out() out.stdout = vim.json.encode({ message = "Done" }) client._make_on_exit({ "curl" }, "/ping", function(data) seen = data end, function() - error("on_error_callback should not run") + error("on_error should not run") end)(out) wait_for(function() return seen ~= nil From dc8de6d728795e3b120dacc23cca170a9c9307ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Fri, 14 Aug 2026 21:25:06 +0200 Subject: [PATCH 7/8] feat: show time since last update in winbar even when updating last_updated doubled as the in-flight marker, so the timestamp disappeared while a refresh ran. Track the two states separately, and drive the spinner phase from vim.uv.hrtime so it advances with wall-clock time rather than with the number of winbar renders. --- lua/gitlab/actions/discussions/init.lua | 4 ++-- lua/gitlab/actions/discussions/winbar.lua | 29 ++++++++++++++--------- lua/gitlab/actions/draft_notes/init.lua | 2 +- lua/gitlab/state.lua | 28 ++++++++++------------ 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 5a551e83..0c207602 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -61,6 +61,7 @@ M.rebuild_view = function(unlinked, all) M.rebuild_discussion_tree() end state.discussion_tree.last_updated = os.time() + state.discussion_tree.updating = false M.refresh_diagnostics() end) end @@ -76,7 +77,7 @@ M.load_discussions = function(callback) state.ahead_behind = { ahead, behind } end ) - state.discussion_tree.last_updated = nil + state.discussion_tree.updating = true state.load_new_state("discussion_data", function(data) if not state.DISCUSSION_DATA then state.DISCUSSION_DATA = {} @@ -92,7 +93,6 @@ end ---Initialize everything for discussions like setup of signs, callbacks for reviewer, etc. M.initialize_discussions = function() - state.discussion_tree.last_updated = os.time() signs.setup_signs() reviewer.set_callback_for_file_changed(function(args) diagnostics.place_diagnostics(args.buf) diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index f59dbee6..3b24b974 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -44,21 +44,28 @@ local get_data = function(nodes) return total_resolvable, total_resolved, total_non_resolvable end -local spinner_index = 0 -state.discussion_tree.last_updated = nil - ----Return the raw content of the winbar. +---Return the time since last update and a spinner if an update is under way. ---@return string -local function content() - local updated +local function get_last_update() + local parts = {} if state.discussion_tree.last_updated then local last_update = tostring(os.date("!%Y-%m-%dT%H:%M:%S", state.discussion_tree.last_updated)) - updated = u.time_since(last_update) .. " ⟳" - else - spinner_index = (spinner_index % #state.settings.discussion_tree.spinner_chars) + 1 - updated = state.settings.discussion_tree.spinner_chars[spinner_index] + table.insert(parts, u.time_since(last_update)) end + local ms = vim.uv.hrtime() / 1e6 -- ns -> ms + local spinner_index = math.floor(ms / 100) % #state.settings.discussion_tree.spinner_chars + 1 + local spinner_char = state.settings.discussion_tree.spinner_chars[spinner_index] + table.insert( + parts, + state.discussion_tree.updating and spinner_char or (state.discussion_tree.last_updated and "⟳" or "never updated") + ) + return table.concat(parts, " ") +end + +---Return the raw content of the winbar. +---@return string +local function content() local resolvable_discussions, resolved_discussions, non_resolvable_discussions = get_data(state.DISCUSSION_DATA.discussions) local resolvable_notes, resolved_notes, non_resolvable_notes = get_data(state.DISCUSSION_DATA.unlinked_discussions) @@ -88,7 +95,7 @@ local function content() help_keymap = state.settings.keymaps.help, ahead = state.ahead_behind[1], behind = state.ahead_behind[2], - updated = updated, + updated = get_last_update(), } return state.settings.discussion_tree.winbar and state.settings.discussion_tree.winbar(t) or M.make_winbar(t) diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 02a9ab0c..169a31c9 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -29,7 +29,7 @@ end ---Make API call to get the discussion data, store it in the state, and call the callback. ---@param callback? fun() M.load_draft_notes = function(callback) - state.discussion_tree.last_updated = nil + state.discussion_tree.updating = true state.load_new_state("draft_notes", function() if callback ~= nil then callback() diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 2635fd0a..2f303c0b 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -8,6 +8,19 @@ local List = require("gitlab.utils.list") local M = { ahead_behind = { nil, nil }, + -- Initial states of the discussion trees + discussion_tree = { + last_updated = nil, + updating = false, + resolved_expanded = false, + unresolved_expanded = false, + }, + unlinked_discussion_tree = { + resolved_expanded = false, + unresolved_expanded = false, + }, + -- Used to set a specific MR when choosing a merge request + chosen_mr_iid = 0, } ---Return a gitlab token and a gitlab URL required to connect to Gitlab. @@ -313,21 +326,6 @@ M.settings = { }, } --- These are the initial states of the discussion trees --- TODO: Move to M definition. -M.discussion_tree = { - resolved_expanded = false, - unresolved_expanded = false, -} -M.unlinked_discussion_tree = { - resolved_expanded = false, - unresolved_expanded = false, -} - --- Used to set a specific MR when choosing a merge request --- TODO: Move to M definition. -M.chosen_mr_iid = 0 - ---Set global keymaps. ---To be used when the plugin is initialized. M.set_global_keymaps = function() From f75a1a072a83aec1f2548f3bc6c770c95aa8128a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Bortl=C3=ADk?= Date: Mon, 17 Aug 2026 18:11:26 +0200 Subject: [PATCH 8/8] feat: use counter for updates A single Boolean `updating` flag stopped the spinner as soon as the first of two overlapping refreshes finished, suggesting all work was done. An integer counter updates for each refresh individually, so the spinner runs until the last one completes. --- lua/gitlab/actions/discussions/init.lua | 19 ++++++++++--------- lua/gitlab/actions/discussions/winbar.lua | 3 ++- lua/gitlab/actions/draft_notes/init.lua | 23 +++++++++++------------ lua/gitlab/state.lua | 14 +++++++------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 0c207602..70a952dc 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -60,15 +60,14 @@ M.rebuild_view = function(unlinked, all) else M.rebuild_discussion_tree() end - state.discussion_tree.last_updated = os.time() - state.discussion_tree.updating = false M.refresh_diagnostics() end) end ----Make API call to get the discussion data, stores it in the state, and calls the callback. ----@param callback? fun() -M.load_discussions = function(callback) +---Make API call to get the discussion data, store it in the state, and call the callback. +---@param on_success fun() +M.load_discussions = function(on_success) + state.discussion_tree.updating = state.discussion_tree.updating + 1 local git = require("gitlab.git") require("gitlab.git_async").get_ahead_behind( git.get_current_branch(), @@ -77,7 +76,6 @@ M.load_discussions = function(callback) state.ahead_behind = { ahead, behind } end ) - state.discussion_tree.updating = true state.load_new_state("discussion_data", function(data) if not state.DISCUSSION_DATA then state.DISCUSSION_DATA = {} @@ -85,9 +83,12 @@ M.load_discussions = function(callback) state.DISCUSSION_DATA.discussions = u.ensure_table(data.discussions) state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(data.unlinked_discussions) state.DISCUSSION_DATA.emojis = u.ensure_table(data.emojis) - if callback ~= nil then - callback() - end + state.discussion_tree.last_updated = os.time() + state.discussion_tree.updating = state.discussion_tree.updating - 1 + on_success() + end, function(data) + client.notify_error(data) + state.discussion_tree.updating = state.discussion_tree.updating - 1 end) end diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 3b24b974..94ad6105 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -58,7 +58,8 @@ local function get_last_update() local spinner_char = state.settings.discussion_tree.spinner_chars[spinner_index] table.insert( parts, - state.discussion_tree.updating and spinner_char or (state.discussion_tree.last_updated and "⟳" or "never updated") + state.discussion_tree.updating > 0 and spinner_char + or (state.discussion_tree.last_updated and "⟳" or "never updated") ) return table.concat(parts, " ") end diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 169a31c9..25be17b1 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -27,13 +27,16 @@ M.rebuild_view = function(unlinked, all) end ---Make API call to get the discussion data, store it in the state, and call the callback. ----@param callback? fun() -M.load_draft_notes = function(callback) - state.discussion_tree.updating = true +---@param on_success fun() +M.load_draft_notes = function(on_success) + state.discussion_tree.updating = state.discussion_tree.updating + 1 state.load_new_state("draft_notes", function() - if callback ~= nil then - callback() - end + state.discussion_tree.last_updated = os.time() + state.discussion_tree.updating = state.discussion_tree.updating - 1 + on_success() + end, function(data) + client.notify_error(data) + state.discussion_tree.updating = state.discussion_tree.updating - 1 end) end @@ -100,9 +103,7 @@ M.confirm_publish_all_drafts = function() state.DRAFT_NOTES = {} require("gitlab.actions.discussions").rebuild_view(false, true) end, function(data) - if data then - u.notify(string.format("%s: %s", data.message, data.error), vim.log.levels.ERROR) - end + client.notify_error(data) u.notify( "Draft(s) may have been published despite the error. Check the discussion tree. Try publishing drafts individually.", vim.log.levels.WARN @@ -132,9 +133,7 @@ M.confirm_publish_draft = function(tree) u.notify(data.message, vim.log.levels.INFO) M.rebuild_view(unlinked) end, function(data) - if data then - u.notify(string.format("%s: %s", data.message, data.error), vim.log.levels.ERROR) - end + client.notify_error(data) u.notify("Draft may have been published despite the error. Check the discussion tree.", vim.log.levels.WARN) M.rebuild_view(unlinked) end) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 2f303c0b..0d25b264 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -11,7 +11,7 @@ local M = { -- Initial states of the discussion trees discussion_tree = { last_updated = nil, - updating = false, + updating = 0, resolved_expanded = false, unresolved_expanded = false, }, @@ -634,8 +634,9 @@ M.dependencies = { ---Load new state for a dependency and execute callback with the data it returns. ---@param dep string The dependency name to re-load ----@param cb fun(data) The function to call with the dependency data -M.load_new_state = function(dep, cb) +---@param on_success fun(data:SuccessResponse) The function to call with the dependency data +---@param on_error? fun(data:ErrorResponse?) The function to call when the request fails +M.load_new_state = function(dep, on_success, on_error) local client = require("gitlab.client") local dependency = M.dependencies[dep] client.send_request( @@ -646,10 +647,9 @@ M.load_new_state = function(dep, cb) if dependency.key then M[dependency.state] = u.ensure_table(data[dependency.key]) end - if type(cb) == "function" then - cb(data) -- To set data manually... - end - end + on_success(data) -- To set data manually... + end, + on_error ) end