No description
  • Emacs Lisp 99.8%
  • Makefile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium 7de408e920
fix(sync): hold the watermark until the pulled buffer reaches disk
A pull writes to a buffer, but the watermark was committed at pull
time.  Neither entry point saves that buffer on its own -- and once the
watermark has moved past an update, `date_updated_gt` never asks the API
for it again, so the entry stays wrong forever with nothing to repair it.

Seen in practice: a task closed on the server kept showing as open in
org, and its update timestamp matched the persisted watermark exactly --
the sync had fetched it, then lost it with the unsaved buffer.

Committing from `after-save-hook` ties the watermark to durable content
instead.  A buffer that dies unsaved takes the pending value with it and
the next sync re-fetches the same window; upserts are idempotent, so
replaying costs a request and a silent hole costs the data.

Assisted-by: Claude Code Opus 5
2026-08-03 14:09:20 +09:00
docs fix(sync): hold the watermark until the pulled buffer reaches disk 2026-08-03 14:09:20 +09:00
lisp fix(sync): hold the watermark until the pulled buffer reaches disk 2026-08-03 14:09:20 +09:00
test fix(sync): hold the watermark until the pulled buffer reaches disk 2026-08-03 14:09:20 +09:00
.gitignore build: scaffold project with Makefile, minimal entry point, and PBT test helper 2026-05-17 14:44:26 +09:00
Makefile feat(api): HTTP execution, rate-limit governor, and tasks pagination 2026-05-17 14:53:19 +09:00
README.org feat(status): cycle a task through its List's statuses from org 2026-07-29 15:16:46 +09:00

org-clickup

Sync ClickUp tasks with org-mode TODO entries from inside Emacs.

Status: pre-release (0.1.0-pre). The package is usable end-to-end for personal task tracking but the API surface may still shift. See the roadmap for what's done and what's coming.

What it does

  • Pulls tasks from ClickUp into one org file per List, with watermark-based incremental sync.
  • Pushes edits back to ClickUp: name, description, status, priority, due/start dates, time estimate, assignees, tags.
  • Renders display names (alice,bob, Engineering, ACME) alongside the raw IDs so the org buffer reads like a project view rather than a database dump.
  • Nests subtasks under their parent on pull, and creates subtasks on ClickUp when you write a heading under another task in org.
  • Detects server-side conflicts before pushing, with a configurable resolution policy.
  • Skips redundant pushes via a content hash, so a sync after push is a no-op.
  • Lets you read and post comments on demand (no per-task GET on every sync).

Requirements

  • Emacs 29.1 or newer (uses built-in json-parse-string, json-serialize, secure-hash, auth-source, modern org).
  • A ClickUp Personal API Token (pk_...). Generate one at https://app.clickup.com/settings/apps.

Installation

Until the package is on a package archive, clone the repo and add it to your load-path:

(add-to-list 'load-path "/path/to/org-clickup/lisp")
(require 'org-clickup)
(require 'org-clickup-setup)
(require 'org-clickup-sync)

Setup

Run the first-run wizard once:

M-x org-clickup-setup

It will:

  1. Prompt you for the pk_... token (read via read-passwd).
  2. Validate it against GET /team.
  3. Let you pick a workspace if you have more than one.
  4. Persist the token via auth-source (so ~/.authinfo.gpg if you use GPG, otherwise wherever your auth-source chain points).
  5. Save the chosen workspace id as org-clickup-team-id via Customize.

Per-file configuration

Each org file that mirrors a ClickUp List gets a small header. Use the discovery wizard to drill down through Workspace → Space → Folder → List and write the header for you:

M-x org-clickup-insert-list-header

Or write it by hand:

#+TITLE: Engineering tasks
#+CLICKUP_TEAM_ID: 1234567
#+CLICKUP_LIST_ID: 901234567890

#+CLICKUP_LIST_ID: is optional — if you omit it, sync hits the workspace-wide Get Filtered Team Tasks endpoint instead of the per-List one.

To pull across multiple Lists, or to filter by assignee, use the optional CSV keywords:

#+CLICKUP_LIST_IDS:  901,902,903   ; multi-List filter (workspace-wide)
#+CLICKUP_ASSIGNEES: 12,34         ; filter to those user IDs

#+CLICKUP_LIST_IDS: takes precedence over #+CLICKUP_LIST_ID: and routes through Get Filtered Team Tasks with list_ids[], since the per-List endpoint can only scope to one List. #+CLICKUP_ASSIGNEES: applies to whichever endpoint is selected.

The discovery wizard (org-clickup-insert-list-header) also asks whether to add an assignee filter and, if yes, lets you multi-select workspace members; the resulting #+CLICKUP_ASSIGNEES: line is written alongside the team/list keywords. At the Space picker, the wizard surfaces a (all Lists in workspace) entry — pick it to skip the rest of the drill-down and write a workspace-wide header (no #+CLICKUP_LIST_ID:), useful for a personal "everything assigned to me" file when combined with #+CLICKUP_ASSIGNEES:.

Startup visibility

Every task lands as a subtree carrying a property drawer and a :CLICKUP: drawer, so a whole List reads as a wall of text unfolded. The wizard writes a #+STARTUP: line for you:

#+STARTUP: overview

Set org-clickup-startup-visibility to pick a different keyword (content to show all headlines with bodies folded, showeverything to fold nothing), or to nil to skip the line and leave visibility to org-startup-folded. An existing #+STARTUP: line in the file is never overwritten, so a value you tuned by hand survives a re-run of the wizard.

Note that #+STARTUP: applies when the file is opened: subtrees that org-clickup-sync inserts into a buffer you already have open appear unfolded until you refold them (S-TAB) or revisit the file.

Workflow

Pull from ClickUp

M-x org-clickup-sync-buffer

Incrementally fetches tasks updated since the last successful sync (per-file watermark). On the first run, fetches everything.

Pull every configured file at once

M-x org-clickup-sync

Sweeps every file in org-clickup-sync-files and pulls each one — without needing to visit the buffer first. Files org-clickup opens itself are saved and closed afterwards; a buffer you already had open is left modified for you to review. One file failing (missing file, no team id, network error) does not abort the rest; failures are reported at the end.

org-clickup-sync-files is populated automatically the first time org-clickup-insert-list-header writes a header into a file. You can also edit the list via M-x customize-variable RET org-clickup-sync-files, or drive a periodic background pull from a timer:

(run-at-time t 900 #'org-clickup-sync)   ; every 15 minutes

Edit a task in org

Just edit the headline, description (inside the :CLICKUP: drawer), DEADLINE, SCHEDULED, EFFORT, etc. — or change the values inside the property drawer (:CLICKUP_STATUS:, :CLICKUP_PRIORITY:, :CLICKUP_ASSIGNEE_IDS:, etc.).

Status

The TODO keyword is a derived view of :CLICKUP_STATUS: — cycling it with C-c C-t does not transition the task, and the property wins on push. To actually change the status:

M-x org-clickup-cycle-status   ; pick from the List's own statuses
M-x org-clickup-mark-done      ; first status ClickUp types as closed

Both write :CLICKUP_STATUS: / :CLICKUP_STATUS_TYPE: and re-derive the TODO keyword exactly as a pull would. The change stays local — push it with org-clickup-push-at-point, so several edits go up in one request and you never get a network round-trip mid-edit.

Editing the property by hand works too: C-c C-x p on :CLICKUP_STATUS: completes over the same List statuses (no free input — ClickUp rejects a status it does not define). Custom statuses are per-List, so the vocabulary is fetched from GET /list/{id} once per session and cached; M-x org-clickup-cache-clear refetches after you add a status in ClickUp.

Custom fields

Custom field values render into the property drawer on pull, each under a property named after the field (so Story Points becomes :CLICKUP_FIELD_STORY_POINTS:). The field's ClickUp id rides alongside in a :CLICKUP_FIELD_..._ID: companion — that is what identifies the field on push, so you read and edit by name and never have to know the raw id:

:PROPERTIES:
,...
:CLICKUP_FIELD_STORY_POINTS:    5
:CLICKUP_FIELD_STORY_POINTS_ID: a1b2c3d4-...
:CLICKUP_FIELD_PRIORITY:        High
:CLICKUP_FIELD_PRIORITY_ID:     e5f6...
:END:

Edit the value like any property — C-c C-x p on a drop-down or label field completes its configured options — and push it back with org-clickup-push-at-point. Each changed field is sent to ClickUp's POST /task/{id}/field/{id} endpoint (one call per field), encoded for its type, and only when it actually differs from the server. Custom-field push needs the pre-push conflict GET, so it is skipped under org-clickup-trust-local. Removing a property line does not delete the field on ClickUp.

To override the auto-generated property name on a given List, set org-clickup-custom-field-map (optional):

(setq org-clickup-custom-field-map
      '(("901234567890"            ; List ID
         ("Story Points" . "SP"))))

Push your edits

M-x org-clickup-push-at-point

The pipeline:

  1. Compares the entry's :CLICKUP_LOCAL_HASH: to a freshly computed one — if unchanged, skips silently.
  2. Pre-push GET to detect server-side conflicts (unless org-clickup-trust-local is t).
  3. PUTs scalar fields plus an assignees add/remove diff.
  4. Issues one POST/DELETE per added/removed tag.
  5. Writes the new :CLICKUP_UPDATED: and :CLICKUP_LOCAL_HASH: back to the entry.

Create a new task

Write a regular org TODO heading anywhere in a configured file, then:

M-x org-clickup-create-from-heading

If the heading sits under an existing ClickUp task (with a :CLICKUP_TASK_ID:), the new task is created as a subtask of that parent in ClickUp too.

Comments

M-x org-clickup-fetch-comments-at-point   ; pull all comments
M-x org-clickup-add-comment-at-point      ; post a new one

Comments render as level+1 sub-headings under the task, with :CLICKUP_COMMENT_ID: for in-place refresh on re-fetch.

Configuration

Common =defcustom=s:

Variable Default Effect
org-clickup-team-id nil Workspace ID. Set by the wizard.
org-clickup-conflict-strategy prompt prompt / server-wins / org-wins / abort on push-time conflict.
org-clickup-trust-local nil Skip the pre-push GET. Saves an API call; loses conflict detection and the assignees/tags diff.
org-clickup-include-closed t Include closed tasks on pull.
org-clickup-use-status-as-todo nil When non-nil, use ClickUp status verbatim as the TODO keyword (e.g. IN-PROGRESS); else collapse.
org-clickup-priority-map =((1 . ?A) (2 . ?A) …) = ClickUp priority int → org priority char.
org-clickup-drawer-name "CLICKUP" Name of the drawer holding the task description.
org-clickup-custom-field-map nil Optional per-List override of custom-field property names; by default the key is derived from the field name.
org-clickup-startup-visibility "overview" #+STARTUP: keyword the header wizard writes into a file that has none; nil to write no line.

See the org-clickup Customize group for the full list.

Roadmap

Implemented (Phase 04):

  • Pure HTTP/SDK layer with rate-limit governor and pagination
  • Read-only sync with watermark-based incremental fetch
  • Onboarding wizard (org-clickup-setup) and List discovery
  • ID-to-name resolution in the property drawer
  • Scalar-field push with conflict detection and hash-skip
  • Assignees and tags diff push
  • Comments (on-demand fetch and add)
  • Subtask nesting on pull, parent-aware task creation
  • Registry-wide pull (org-clickup-sync over org-clickup-sync-files, no buffer needed)

Coming (Phase 5+, see docs/design.md):

  • Phase 5 — Custom fields (per-List name → property map): rendered on pull, pushed via POST /task/{id}/field/{id} with per-type encoding and diff-vs-server, plus C-c C-x p completion of drop-down/label options
  • Phase 6 — UX polish: status cycle command (org-clickup-cycle-status / org-clickup-mark-done, plus C-c C-x p completion of the List's statuses) shipped; transient menu and [[clickup:abc12]] link type still pending
  • Phase 7 — Verbatim status mapping, property overrides
  • Phase 8 — Deletion reconciliation, backup retention, 3-way merge for conflict resolution
  • Filter keywords — #+CLICKUP_ASSIGNEES: (numeric IDs) and #+CLICKUP_LIST_IDS: (multi-List) shipped; me shorthand and #+CLICKUP_STATUSES: still pending
  • Org-capture and refile hooks
  • Buffer-wide push (org-clickup-push-buffer)

Development

make test         # run the full ert suite
make test-quick   # skip :slow tagged tests
make compile      # byte-compile lisp/*.el with -Werror

No external test dependencies — a tiny property-based testing helper is bundled in test/test-helper.el.

Architecture and rationale: docs/design.md. Prior-art studies (org-trello, org-jira, org-gcal) live alongside it in docs/prior-art/.