feat(provider): GitLab forge and ClickUp tracker providers #37

Merged
ryangr0 merged 1 commit from feat/gitlab-clickup-providers into development 2026-08-25 14:34:11 +00:00
Owner

Ploeg shipped one provider per seam — Vikunja and Forgejo — which is enough to prove the SPI and not enough to run anywhere else. Code14's staging cluster is GitLab and ClickUp, so both seams needed a second implementation before Ploeg could be exercised there at all (see RFC-0007 in code14/staging-cluster).

Both are pure SPI implementations. No core change, nothing vendor-shaped escapes either package (R7), and each registers under its dialect name so the existing /webhooks/{tracker,forge}/{provider} routes reach them unchanged.

GitLab — three things a copy-paste of the Forgejo provider gets silently wrong

No signature GitLab echoes a shared secret in X-Gitlab-Token — it authenticates the sender, not the payload. Compared in constant time; a bare == leaks a secret a byte at a time.
Two numbers A merge request has id (instance-global, useless in a URL) and iid (what humans and the API use). This uses iid throughout.
Subgroups Projects live at arbitrary depth (group/sub/proj), so the path is URL-encoded whole rather than split into owner/name.

Review outcomes arrive as merge_request actions rather than a review object, and a branch pipeline has no MR — reported as PR 0, which the core reads as "nothing to route this to" rather than as merge request zero.

ClickUp — three more

Raw token Authorization: pk_… with no Bearer prefix; ClickUp 401s the prefixed form. There is a regression test for exactly this.
Inverted priority ClickUp id 1 is urgent; Ploeg counts up. Flipped on the way in rather than leaking backwards ordering into scheduling.
No global "done" Status is a per-List custom string. SetStatus needs DoneStatus configured and skips loudly without it, instead of guessing a name that would 400 or move the task somewhere nobody chose.

ClickUp's webhook is thin and carries no List, so Scope is empty at parse time and resolved in FetchItem — the thin-payload rule doing what it is for.

Wiring

Trackers become a registry built once rather than two inline literals. The forge instance id (ADR-0016) is now bound explicitly: with one forge configured it takes the id; with two and PLOEG_TARGET_FORGE naming neither, nothing is bound and it logs that — publishing findings to the wrong forge is worse than not publishing.

New env, all optional: PLOEG_CLICKUP_{SECRET,TOKEN,URL,DONE_STATUS}, PLOEG_GITLAB_{URL,TOKEN,SECRET}. Absent, behaviour is byte-identical to before.

Gates

go build ./...            ok
go vet ./...              ok
go test ./...             ok  (22 packages, incl. cmd/ploegd wiring tests)
gofmt -l .                clean
helm lint ops/helm/ploeg  1 chart linted, 0 failed
helm template             default / executor / executor-cronjob all render

Tests fake both APIs with httptest and never touch the network.

Not in scope

No chart wiring for the new env yet, and no ClickUp Space/Folder traversal beyond folderless lists — ListsByName deliberately does not conflate folders, since two folders can reuse a list name.

Ploeg shipped one provider per seam — Vikunja and Forgejo — which is enough to prove the SPI and not enough to run anywhere else. Code14's staging cluster is **GitLab and ClickUp**, so both seams needed a second implementation before Ploeg could be exercised there at all (see RFC-0007 in `code14/staging-cluster`). Both are pure SPI implementations. No core change, nothing vendor-shaped escapes either package (R7), and each registers under its dialect name so the existing `/webhooks/{tracker,forge}/{provider}` routes reach them unchanged. ## GitLab — three things a copy-paste of the Forgejo provider gets silently wrong | | | |---|---| | **No signature** | GitLab echoes a shared secret in `X-Gitlab-Token` — it authenticates the *sender*, not the payload. Compared in constant time; a bare `==` leaks a secret a byte at a time. | | **Two numbers** | A merge request has `id` (instance-global, useless in a URL) and `iid` (what humans and the API use). This uses `iid` throughout. | | **Subgroups** | Projects live at arbitrary depth (`group/sub/proj`), so the path is URL-encoded whole rather than split into owner/name. | Review outcomes arrive as `merge_request` **actions** rather than a review object, and a branch pipeline has no MR — reported as `PR 0`, which the core reads as "nothing to route this to" rather than as merge request zero. ## ClickUp — three more | | | |---|---| | **Raw token** | `Authorization: pk_…` with no `Bearer` prefix; ClickUp 401s the prefixed form. There is a regression test for exactly this. | | **Inverted priority** | ClickUp id 1 is *urgent*; Ploeg counts up. Flipped on the way in rather than leaking backwards ordering into scheduling. | | **No global "done"** | Status is a per-List custom string. `SetStatus` needs `DoneStatus` configured and skips loudly without it, instead of guessing a name that would 400 or move the task somewhere nobody chose. | ClickUp's webhook is thin and carries no List, so `Scope` is empty at parse time and resolved in `FetchItem` — the thin-payload rule doing what it is for. ## Wiring Trackers become a registry built once rather than two inline literals. The forge **instance** id (ADR-0016) is now bound explicitly: with one forge configured it takes the id; with two and `PLOEG_TARGET_FORGE` naming neither, nothing is bound and it logs that — publishing findings to the wrong forge is worse than not publishing. New env, all optional: `PLOEG_CLICKUP_{SECRET,TOKEN,URL,DONE_STATUS}`, `PLOEG_GITLAB_{URL,TOKEN,SECRET}`. Absent, behaviour is byte-identical to before. ## Gates ``` go build ./... ok go vet ./... ok go test ./... ok (22 packages, incl. cmd/ploegd wiring tests) gofmt -l . clean helm lint ops/helm/ploeg 1 chart linted, 0 failed helm template default / executor / executor-cronjob all render ``` Tests fake both APIs with `httptest` and never touch the network. ## Not in scope No chart wiring for the new env yet, and no ClickUp Space/Folder traversal beyond folderless lists — `ListsByName` deliberately does not conflate folders, since two folders can reuse a list name.
feat(provider): GitLab forge and ClickUp tracker providers
All checks were successful
On Pull Request / checks (pull_request) Successful in 54s
794eafc056
Ploeg shipped one provider per seam — Vikunja and Forgejo — which is enough to
prove the SPI and not enough to run anywhere else. Code14's staging cluster is
GitLab and ClickUp, so both seams needed a second implementation before Ploeg
could be exercised there at all.

Both are pure SPI implementations: no core change, nothing vendor-shaped
escapes either package (R7), and each is registered under its dialect name so
the existing /webhooks/{tracker,forge}/{provider} routes reach them unchanged.

GitLab differs from Forgejo in three ways a copy-paste gets silently wrong,
and each is commented where it bites:

  - GitLab does NOT sign webhooks. It echoes a shared secret in X-Gitlab-Token,
    which authenticates the sender but not the payload. Compared in constant
    time, since a bare == leaks a secret a byte at a time.
  - A merge request has two numbers; only `iid` works in an API path.
  - Projects live at arbitrary subgroup depth, so the path is URL-encoded whole
    rather than split into owner/name.

Review outcomes also arrive as merge_request actions rather than a review
object, and a branch pipeline has no MR — reported as PR 0, which the core
reads as "nothing to route this to" rather than as merge request zero.

ClickUp differs from Vikunja in three ways, likewise commented:

  - Auth is the raw token, no "Bearer " prefix (ClickUp 401s the prefixed form).
  - Priority is inverted — id 1 is urgent — and is flipped on the way in rather
    than leaking backwards ordering into scheduling.
  - There is no global "done": status is a per-List custom string, so SetStatus
    needs DoneStatus configured and skips loudly without it instead of guessing
    a name that would 400 or move the task somewhere nobody chose.

ClickUp's webhook is thin and carries no List, so Scope is left empty at parse
time and resolved in FetchItem — the thin-payload rule doing exactly what it is
for.

Wiring: trackers become a registry built once rather than two inline literals,
and the forge instance id (ADR-0016) is bound explicitly. With one forge
configured it takes the id; with two and PLOEG_TARGET_FORGE naming neither,
nothing is bound and it says so — publishing findings to the wrong forge is
worse than not publishing.

New env: PLOEG_CLICKUP_{SECRET,TOKEN,URL,DONE_STATUS},
PLOEG_GITLAB_{URL,TOKEN,SECRET}. Absent, behaviour is byte-identical to before.

Gates: go build, go vet, go test ./... (all green, including cmd/ploegd's
existing wiring tests), gofmt clean, helm lint and all three chart renderings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ryangr0 merged commit 2b9b134bd6 into development 2026-08-25 14:34:11 +00:00
Commenting is not possible because the repository is archived.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
webgrip/ploeg!37
No description provided.