local M = {}
local V2 = "/v2"
local V3 = "/v3"
function M.client(opts)
opts = opts or {}
local token = opts.token or env.get("CLICKUP_TOKEN")
local base_url = (opts.base_url or "https://api.clickup.com/api"):gsub("/+$", "")
local function headers()
local h = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }
if token then h["Authorization"] = token end
return h
end
local function urlencode(str)
return tostring(str):gsub("([^%w%-%.%_%~])", function(ch)
return string.format("%%%02X", string.byte(ch))
end)
end
local function build_query(params)
if not params then return "" end
local parts = {}
for k, v in pairs(params) do
if type(v) == "table" then
for _, item in ipairs(v) do
parts[#parts + 1] = urlencode(k) .. "%5B%5D=" .. urlencode(item)
end
elseif v ~= nil then
parts[#parts + 1] = urlencode(k) .. "=" .. urlencode(v)
end
end
table.sort(parts)
return #parts > 0 and "?" .. table.concat(parts, "&") or ""
end
local function decode(resp)
if resp.body and resp.body ~= "" then
local ok, parsed = pcall(json.parse, resp.body)
if ok then return parsed end
end
return nil
end
local function api_get(version, path_str, query_params)
local resp = http.get(base_url .. version .. path_str .. build_query(query_params),
{ headers = headers() })
if resp.status == 404 then return nil end
if resp.status ~= 200 then
error("clickup: GET " .. path_str .. " HTTP " .. resp.status .. ": " .. (resp.body or ""))
end
return decode(resp)
end
local function api_send(method, fn, version, path_str, payload, query_params)
local resp = fn(base_url .. version .. path_str .. build_query(query_params), payload or {},
{ headers = headers() })
if resp.status ~= 200 and resp.status ~= 201 and resp.status ~= 204 then
error("clickup: " .. method .. " " .. path_str .. " HTTP " .. resp.status .. ": " ..
(resp.body or ""))
end
return decode(resp)
end
local function api_post(version, path_str, payload, query_params)
return api_send("POST", http.post, version, path_str, payload, query_params)
end
local function api_put(version, path_str, payload, query_params)
return api_send("PUT", http.put, version, path_str, payload, query_params)
end
local function api_delete(version, path_str, query_params)
local resp = http.delete(base_url .. version .. path_str .. build_query(query_params),
{ headers = headers() })
if resp.status ~= 200 and resp.status ~= 204 then
error("clickup: DELETE " .. path_str .. " HTTP " .. resp.status .. ": " .. (resp.body or ""))
end
return true
end
local function items(envelope, key)
if not envelope then return {} end
return envelope[key] or {}
end
local function one(envelope, key)
if not envelope then return nil end
return envelope[key]
end
local c = {}
c.teams = {}
function c.teams:list()
return items(api_get(V2, "/team"), "teams")
end
c.spaces = {}
function c.spaces:list(team_id, query_opts)
return items(api_get(V2, "/team/" .. urlencode(team_id) .. "/space", query_opts), "spaces")
end
function c.spaces:get(space_id)
return api_get(V2, "/space/" .. urlencode(space_id))
end
function c.spaces:create(team_id, space)
return api_post(V2, "/team/" .. urlencode(team_id) .. "/space", space)
end
function c.spaces:tags(space_id)
return items(api_get(V2, "/space/" .. urlencode(space_id) .. "/tag"), "tags")
end
c.folders = {}
function c.folders:list(space_id, query_opts)
return items(api_get(V2, "/space/" .. urlencode(space_id) .. "/folder", query_opts), "folders")
end
function c.folders:get(folder_id)
return api_get(V2, "/folder/" .. urlencode(folder_id))
end
function c.folders:create(space_id, folder)
return api_post(V2, "/space/" .. urlencode(space_id) .. "/folder", folder)
end
function c.folders:delete(folder_id)
return api_delete(V2, "/folder/" .. urlencode(folder_id))
end
c.lists = {}
function c.lists:list(folder_id, query_opts)
return items(api_get(V2, "/folder/" .. urlencode(folder_id) .. "/list", query_opts), "lists")
end
function c.lists:folderless(space_id, query_opts)
return items(api_get(V2, "/space/" .. urlencode(space_id) .. "/list", query_opts), "lists")
end
function c.lists:get(list_id)
return api_get(V2, "/list/" .. urlencode(list_id))
end
function c.lists:create(folder_id, list)
return api_post(V2, "/folder/" .. urlencode(folder_id) .. "/list", list)
end
function c.lists:create_folderless(space_id, list)
return api_post(V2, "/space/" .. urlencode(space_id) .. "/list", list)
end
function c.lists:update(list_id, patch)
return api_put(V2, "/list/" .. urlencode(list_id), patch)
end
function c.lists:delete(list_id)
return api_delete(V2, "/list/" .. urlencode(list_id))
end
function c.lists:members(list_id)
return items(api_get(V2, "/list/" .. urlencode(list_id) .. "/member"), "members")
end
c.tasks = {}
function c.tasks:page(list_id, query_opts)
return api_get(V2, "/list/" .. urlencode(list_id) .. "/task", query_opts)
or { tasks = {}, last_page = true }
end
function c.tasks:list(list_id, query_opts)
return items(self:page(list_id, query_opts), "tasks")
end
function c.tasks:get(task_id, query_opts)
return api_get(V2, "/task/" .. urlencode(task_id), query_opts)
end
function c.tasks:create(list_id, task)
return api_post(V2, "/list/" .. urlencode(list_id) .. "/task", task)
end
function c.tasks:update(task_id, patch)
return api_put(V2, "/task/" .. urlencode(task_id), patch)
end
function c.tasks:delete(task_id)
return api_delete(V2, "/task/" .. urlencode(task_id))
end
function c.tasks:filtered(team_id, query_opts)
return api_get(V2, "/team/" .. urlencode(team_id) .. "/task", query_opts)
or { tasks = {}, last_page = true }
end
function c.tasks:members(task_id)
return items(api_get(V2, "/task/" .. urlencode(task_id) .. "/member"), "members")
end
c.comments = {}
function c.comments:list(task_id, query_opts)
return items(api_get(V2, "/task/" .. urlencode(task_id) .. "/comment", query_opts), "comments")
end
function c.comments:create(task_id, body)
local payload = type(body) == "table" and body or { comment_text = tostring(body) }
return api_post(V2, "/task/" .. urlencode(task_id) .. "/comment", payload)
end
function c.comments:update(comment_id, patch)
return api_put(V2, "/comment/" .. urlencode(comment_id), patch)
end
function c.comments:delete(comment_id)
return api_delete(V2, "/comment/" .. urlencode(comment_id))
end
c.goals = {}
function c.goals:list(team_id, query_opts)
return items(api_get(V2, "/team/" .. urlencode(team_id) .. "/goal", query_opts), "goals")
end
function c.goals:get(goal_id)
return one(api_get(V2, "/goal/" .. urlencode(goal_id)), "goal")
end
function c.goals:create(team_id, goal)
return one(api_post(V2, "/team/" .. urlencode(team_id) .. "/goal", goal), "goal")
end
function c.goals:update(goal_id, patch)
return one(api_put(V2, "/goal/" .. urlencode(goal_id), patch), "goal")
end
function c.goals:delete(goal_id)
return api_delete(V2, "/goal/" .. urlencode(goal_id))
end
c.fields = {}
function c.fields:list(list_id)
return items(api_get(V2, "/list/" .. urlencode(list_id) .. "/field"), "fields")
end
function c.fields:space(space_id)
return items(api_get(V2, "/space/" .. urlencode(space_id) .. "/field"), "fields")
end
function c.fields:team(team_id)
return items(api_get(V2, "/team/" .. urlencode(team_id) .. "/field"), "fields")
end
function c.fields:set(task_id, field_id, value, query_opts)
api_put(V2, "/task/" .. urlencode(task_id) .. "/field/" .. urlencode(field_id),
{ value = value }, query_opts)
return true
end
function c.fields:remove(task_id, field_id)
return api_delete(V2, "/task/" .. urlencode(task_id) .. "/field/" .. urlencode(field_id))
end
c.time = {}
function c.time:entries(team_id, query_opts)
return items(api_get(V2, "/team/" .. urlencode(team_id) .. "/time_entries", query_opts), "data")
end
function c.time:running(team_id, query_opts)
local envelope = api_get(V2, "/team/" .. urlencode(team_id) .. "/time_entries/running",
query_opts)
return envelope and envelope.data or nil
end
function c.time:create(team_id, entry)
return api_post(V2, "/team/" .. urlencode(team_id) .. "/time_entries", entry)
end
function c.time:stop(team_id)
return api_post(V2, "/team/" .. urlencode(team_id) .. "/time_entries/stop", {})
end
c.docs = {}
function c.docs:search(workspace_id, query_opts)
return api_get(V3, "/workspaces/" .. urlencode(workspace_id) .. "/docs", query_opts)
or { docs = {} }
end
function c.docs:get(workspace_id, doc_id)
return api_get(V3, "/workspaces/" .. urlencode(workspace_id) .. "/docs/" .. urlencode(doc_id))
end
function c.docs:create(workspace_id, doc)
return api_post(V3, "/workspaces/" .. urlencode(workspace_id) .. "/docs", doc)
end
function c.docs:pages(workspace_id, doc_id, query_opts)
return api_get(V3, "/workspaces/" .. urlencode(workspace_id) .. "/docs/" ..
urlencode(doc_id) .. "/pages", query_opts) or {}
end
function c.docs:create_page(workspace_id, doc_id, page)
return api_post(V3, "/workspaces/" .. urlencode(workspace_id) .. "/docs/" ..
urlencode(doc_id) .. "/pages", page)
end
function c.docs:edit_page(workspace_id, doc_id, page_id, patch)
local path_str = "/workspaces/" .. urlencode(workspace_id) .. "/docs/" ..
urlencode(doc_id) .. "/pages/" .. urlencode(page_id)
return api_send("PATCH", http.patch, V3, path_str, patch)
end
function c.discover()
return {
base_url = base_url,
authenticated = token ~= nil,
sections = {
"teams", "spaces", "folders", "lists", "tasks",
"comments", "goals", "fields", "time", "docs",
},
}
end
return c
end
function M.all_tasks(c, list_id, opts)
opts = opts or {}
local max_pages = opts.max_pages or 100
local query = {}
for k, v in pairs(opts) do
if k ~= "max_pages" then query[k] = v end
end
local out = {}
for page = 0, max_pages - 1 do
query.page = page
local envelope = c.tasks:page(list_id, query)
for _, task in ipairs(envelope and envelope.tasks or {}) do
out[#out + 1] = task
end
if not envelope or envelope.last_page ~= false then break end
end
return out
end
function M.find_task_by_name(c, list_id, name, opts)
for _, task in ipairs(M.all_tasks(c, list_id, opts)) do
if task.name == name then return task end
end
return nil
end
function M.ensure_task(c, list_id, spec, opts)
if type(spec) ~= "table" or not spec.name then
error("clickup: ensure_task requires spec.name")
end
local existing = M.find_task_by_name(c, list_id, spec.name, opts)
if existing then return existing end
return c.tasks:create(list_id, spec)
end
function M.resolve_team(c, name)
local teams = c.teams:list()
if name then
for _, team in ipairs(teams) do
if team.name == name then return team end
end
error("clickup: no workspace named " .. tostring(name))
end
if #teams == 0 then error("clickup: token can see no workspaces") end
if #teams > 1 then
error("clickup: token sees " .. #teams .. " workspaces; pass a name to disambiguate")
end
return teams[1]
end
return M