kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

ci: add spindle mirror to github

+816
+24
.tangled/workflows/mirror-to-github.yaml
··· 1 + when: 2 + - event: ["push", "manual"] 3 + branch: ["main", "feat/*", "fix/*"] 4 + 5 + engine: "nixery" 6 + 7 + clone: 8 + depth: 0 9 + 10 + dependencies: 11 + nixpkgs: 12 + - git 13 + 14 + steps: 15 + - name: "Debug branch variable" 16 + command: | 17 + echo "Branch: ${SPINDLE_BRANCH}" 18 + - name: "Mirror to GitHub" 19 + command: | 20 + git fetch --unshallow origin || true 21 + git remote add github "https://x-access-token:${GITHUB_PAT}@github.com/daniel-daum/kaneo.git" 22 + git push github "HEAD:refs/heads/${SPINDLE_BRANCH}" --force 23 + environment: 24 + GITHUB_PAT: "${{ secrets.GITHUB_PAT }}"
+792
AGENTS.md
··· 1 + # Kaneo — Project Context for Oz 2 + 3 + ## What This Project Is 4 + 5 + Kaneo is a self-hosted Kanban/project management app (pnpm monorepo). I've forked it from the original 6 + author and am hosting my fork on **tangled.org** (a decentralized Git platform built on AT Protocol / atproto). 7 + 8 + The app already has a **two-way GitHub Issues sync**: create a task in Kaneo → issue appears on GitHub, 9 + comment on GitHub → syncs back to Kaneo. The existing GitHub integration lives in: 10 + - Backend plugin: `apps/api/src/plugins/github/` 11 + - Backend routes/controllers: `apps/api/src/github-integration/` 12 + - Frontend fetchers: `apps/web/src/fetchers/github-integration/` 13 + - Frontend hooks: `apps/web/src/hooks/mutations/github-integration/` and `hooks/queries/github-integration/` 14 + - Frontend component: `apps/web/src/components/project/github-integration-settings.tsx` 15 + 16 + ## My Goal 17 + 18 + Build a **Tangled/AT Protocol two-way sync integration** for Kaneo — mirroring the GitHub integration 19 + but for tangled.org. If you create a Kaneo task, it creates a `sh.tangled.repo.issue` record on the 20 + user's PDS (and vice versa). Comments, state changes, labels all sync both ways. 21 + 22 + My fork lives at tangled.org. This is a learning project — I am new to TypeScript in a complex 23 + monorepo but have experience in Python, Go, React (light), and SQL. 24 + 25 + **Fork URLs:** 26 + - Tangled (primary): `https://tangled.org/danieldaum.net/kaneo` 27 + - GitHub fork (for upstream PRs): `https://github.com/daniel-daum/kaneo` 28 + - Upstream: `https://github.com/usekaneo/kaneo` 29 + 30 + ## Mentorship Mode — IMPORTANT 31 + 32 + **Do not generate large blocks of code for me unless I explicitly ask.** 33 + 34 + My primary goal is to *learn* while building this. The preferred interaction style is: 35 + - Explain *why* before *how* 36 + - Show me the relevant existing code pattern, then ask me to try writing it 37 + - Point me to the file/function to look at, let me read it first 38 + - Review and correct what I write rather than writing it for me 39 + - When I'm stuck, give targeted hints rather than full solutions 40 + - If I ask "how do I do X", explain the concept and show a small example — don't implement X in the codebase for me 41 + 42 + That said: I want to move at a reasonable pace. If something is purely boilerplate/mechanical (e.g. 43 + adding an env var to a config file, or creating an empty directory structure), go ahead and do it. 44 + 45 + ## Tangled / AT Protocol Architecture 46 + 47 + Full reference docs are in `~/starforge/documentation/tangled/`: 48 + - `001-architecture-and-api.md` — API surface, XRPC endpoints, lexicon catalog, webhooks 49 + - `002-atproto-vs-tangled-boundary.md` — exactly which operations go to atproto vs knot vs appview 50 + 51 + ### Key facts to keep in mind: 52 + 53 + **How Tangled works (two tiers):** 54 + 1. **User's PDS** (AT Protocol) — stores records like issues, PRs, labels. Accessed via standard 55 + `com.atproto.repo.*` methods. This is where ~80% of the work happens. 56 + 2. **Knot servers** — host actual git repos. Expose XRPC endpoints for branches, diffs, trees, etc. 57 + Require a service auth token obtained from the user's PDS. 58 + 3. **AppView (tangled.org)** — server-rendered HTML only. NOT an API. Only used for display URLs 59 + and as the `aud` value when getting service auth tokens (`did:web:tangled.org`). 60 + 61 + **Creating an issue on Tangled** = writing a `sh.tangled.repo.issue` record to the user's PDS via 62 + `com.atproto.repo.createRecord`. No Tangled HTTP API involved. 63 + 64 + **Knot discovery** — to call knot XRPC, read the `sh.tangled.repo` record from the owner's PDS 65 + and extract the `knot` field. Never hardcode knot hostnames. 66 + 67 + **Service auth flow for knot calls:** 68 + 1. Auth to user's PDS with handle + app password 69 + 2. `com.atproto.server.getServiceAuth` with `aud: did:web:tangled.org` 70 + 3. Get a short-lived bearer token (60s expiry) 71 + 4. Use it in `Authorization: Bearer {token}` for knot XRPC calls 72 + 73 + **Receiving events from Tangled** → use webhooks (documented at docs.tangled.org/webhooks). 74 + Webhooks are configured per-repo in the repo settings UI. Signed payloads, delivery retries. 75 + 76 + **Relevant atproto collections:** 77 + - `sh.tangled.repo` — repo metadata (name, knot, description) 78 + - `sh.tangled.repo.issue` — issues (fields: `repo` as at-uri, `title`, `body`, `createdAt`) 79 + - `sh.tangled.repo.issue.comment` — issue comments 80 + - `sh.tangled.repo.issue.state` — state change record 81 + - `sh.tangled.label.op` — label apply/remove 82 + - `sh.tangled.label.definition` — label definitions 83 + 84 + **Best third-party reference:** `tangled.org/zzstoatzz/tangled-mcp` — a Python MCP server that 85 + does atproto issue CRUD and knot XRPC calls. Study this when figuring out auth and record writes. 86 + 87 + ## Tech Stack (Kaneo) 88 + 89 + See `CLAUDE.md` for full details. Summary: 90 + - **Backend**: Hono (Node.js), PostgreSQL + Drizzle ORM, Better Auth, Valibot for validation 91 + - **Frontend**: React 19, TanStack Router + Query, Vite, Tailwind CSS v4, Zustand 92 + - **Monorepo**: pnpm + TurboRepo 93 + - **Package manager**: pnpm (not npm/yarn) 94 + - Dev ports: API on 1337, web on 5173 95 + 96 + ## Implementation Roadmap 97 + 98 + The full step-by-step plan is in `~/starforge/documentation/tangled/`. High-level phases: 99 + 100 + 1. **Setup & credentials** — env vars for Tangled handle/app-password, AT Protocol client library 101 + 2. **Outbound sync (Kaneo → Tangled)** — when a task is created/updated/commented in Kaneo, write 102 + the corresponding atproto record to the user's PDS 103 + 3. **Inbound sync (Tangled → Kaneo)** — configure a Tangled webhook, handle incoming payloads, 104 + update Kaneo tasks accordingly 105 + 4. **UI** — settings page for connecting a Tangled repo to a Kaneo project (mirrors GitHub settings) 106 + 5. **State reconciliation** — handle edge cases, prevent sync loops, label mapping 107 + 108 + ## Development Setup — Active Plan (updated 2026-04-18) 109 + ### Current state snapshot 110 + - `origin` = `git@knot.danieldaum.net:danieldaum.net/kaneo` (tangled). No `upstream` or `github` remote configured yet. 111 + - HEAD detached at `383e545`; `updates.md` has been merged into this file (see appendix). 112 + - Local `main` has three personal commits on top of an old `upstream/main` (~408 commits behind `usekaneo/kaneo`): 113 + - `27766df ci: add ci mirror to github` — personal; must NOT leak into an upstream PR 114 + - `6ae6bf5 feat: add atproto/api sdk` — upstream-worthy; belongs on the feature branch 115 + - `383e545 feat: add stubbed tangled plugin type` — upstream-worthy; belongs on the feature branch 116 + - `.tangled/workflows/mirror-to-github.yaml` force-pushes `main` from tangled → github on every push. 117 + - Upstream branch-naming convention (from `CONTRIBUTING.md` and recent merge commits): `feat/<name>` or `fix/<name>`. Chosen feature branch: **`feat/tangled-integration`**. 118 + ### Git model 119 + Three remotes on the local repo (in the orbstack VM at `~/starforge/kaneo`): 120 + - `origin` → tangled knot (`git@knot.danieldaum.net:danieldaum.net/kaneo`) — primary forge, source of truth 121 + - `upstream` → `https://github.com/usekaneo/kaneo.git` — pristine upstream; rebase target and PR target 122 + - `github` → `git@github.com:daniel-daum/kaneo.git` — fork used as PR source (fed by the spindle mirror) 123 + Two long-lived branches on my fork: 124 + - `main` = `upstream/main` + a thin layer of **personal-only** commits (mirror workflow, flake, AGENTS.md). This is the branch that mirrors to GitHub and is never the PR source. 125 + - `feat/tangled-integration` = branched off `upstream/main`; contains the atproto SDK + plugin stub + all future integration work. This becomes the upstream PR. 126 + Flow when contributing: 127 + 1. Work on `feat/tangled-integration` locally. 128 + 2. Push to `origin` (tangled) → spindle mirrors to `github` → open/update a PR on the github fork targeting `usekaneo/kaneo:main`. 129 + 3. When upstream advances, `git fetch upstream && git rebase upstream/main` on the feature branch. 130 + 4. Keep `main` in sync by resetting to `upstream/main` and re-applying the personal commits on top. 131 + ### Personal-file policy 132 + These files live **only** on `main` (or a local branch derived from main), never on the feature branch: 133 + - `AGENTS.md` 134 + - `flake.nix`, `flake.lock`, `.envrc` 135 + - `.tangled/workflows/**` 136 + - Anything we later add under a `.local/` directory 137 + Guardrails: 138 + - Always `git switch main` (or `jj new main@origin`) before editing those files. Never edit them while HEAD is on `feat/tangled-integration`. 139 + - Before pushing a feature branch: `git diff --name-only upstream/main..HEAD` — none of the paths above should appear. 140 + - Optional: a pre-push hook that fails if those paths appear on any `feat/*` branch. 141 + ### Chronological checklist — do ONE step at a time 142 + Stop after each step, verify, then re-read this file before starting the next. Commands are illustrative — pick `git` or `jj` per your preference; `.jj/` is colocated so both work. 143 + 1. **Commit the consolidated `AGENTS.md` on `main`.** 144 + - `jj edit main` (or `git switch main` for plain git). 145 + - `jj describe -m "docs: consolidate integration context into AGENTS.md"` then `jj new` to open a fresh empty `@`. 146 + - Verify `jj status` is clean; `jj log -r main..@` shows the new commit. 147 + 2. **Add the `upstream` and `github` remotes, fetch them.** 148 + - `git remote add upstream https://github.com/usekaneo/kaneo.git` 149 + - `git remote add github git@github.com:daniel-daum/kaneo.git` 150 + - `git fetch upstream && git fetch github` 151 + - Confirm `git log --oneline upstream/main | head` shows ~408 new commits. 152 + 3. **Safety net before rewriting history.** 153 + - `git tag backup/pre-rewrite-main main` 154 + - `git push origin refs/tags/backup/pre-rewrite-main` 155 + 4. **Create `feat/tangled-integration` from `upstream/main` with only the upstream-worthy commits.** 156 + - `git switch -c feat/tangled-integration upstream/main` 157 + - `git cherry-pick 6ae6bf5 383e545` # atproto sdk + stubbed plugin 158 + - Run `pnpm install` and `pnpm lint` to ensure it still builds against new upstream. 159 + - `git push -u origin feat/tangled-integration` 160 + 5. **Rewrite `main` = upstream/main + personal commits only.** 161 + - `git switch main` 162 + - `git reset --hard upstream/main` 163 + - `git cherry-pick 27766df` # ci: add ci mirror to github (personal) 164 + - Re-apply personal files if they are no longer present: `AGENTS.md` and (later) `flake.nix`. 165 + - `git push origin main --force-with-lease` 166 + - Spindle mirrors the new `main` to github; verify on the github fork. 167 + 6. **Extend spindle workflow so feature branches also mirror to github.** 168 + - Edit `.tangled/workflows/mirror-to-github.yaml` on `main`: expand `when.branch` to `["main", "feat/*", "fix/*"]` and push each ref generically (`git push github HEAD:refs/heads/$BRANCH --force`). 169 + - Commit on `main`, push. Then push a no-op commit (or `--allow-empty`) to `feat/tangled-integration` and confirm it appears on the github fork. 170 + 7. **Add the Nix dev flake (minimal profile).** 171 + - On `main`: add `flake.nix` with inputs `nixpkgs`, output `devShells.default` containing: `nodejs_22`, `corepack_22` (for `pnpm@10.32.1` pinning), `git`, `jj`. 172 + - Optional `.envrc` with `use flake` for direnv. 173 + - Commit: `chore(nix): add dev flake`. 174 + - Inside the VM: `nix develop` → `corepack enable` → `pnpm install` → `pnpm dev` must work. 175 + 8. **Open a draft PR on the github fork.** 176 + - From `daniel-daum:feat/tangled-integration` → `usekaneo:main`. 177 + - Mark as draft; fill out `.github/PULL_REQUEST_TEMPLATE.md`. 178 + - Keep as draft until the integration is end-to-end usable. 179 + 9. **Resume integration work** from "Next Session — Start Here" below. All subsequent feature commits land on `feat/tangled-integration`, get mirrored to github, and accumulate on the draft PR. 180 + ### Pitfalls & traps 181 + - **Leaking personal files into the PR.** The single biggest risk. Always diff against `upstream/main` before pushing `feat/*`. 182 + - **Force-pushing `main`.** The mirror workflow already uses `--force`, so this is acceptable. The rule is: never open a PR *from* `main` — always from `feat/*`. 183 + - **`pnpm-lock.yaml` churn.** Pulling 408 upstream commits will change the lockfile and may change Node engine constraints. Always re-run `pnpm install` after the rebase/reset. 184 + - **Drizzle migrations drift.** Upstream likely added migrations. Expect to rebuild the local Postgres or run pending migrations after the upstream pull. 185 + - **jj colocation quirks.** After the `reset --hard` and cherry-picks on `main`, run `jj git import` and verify `jj log` matches. Use `jj bookmark set main -r @` if bookmarks fall behind. 186 + - **SSH/PAT paths differ per remote.** `origin` (ssh → knot), `github` (ssh → github.com), `upstream` (https, read-only). Confirm `ssh -T git@github.com` succeeds inside the VM. The spindle's `GITHUB_PAT` secret must still have `repo` scope for the mirror to push. 187 + - **Spindle only knows `main` today.** If step 6 is skipped, feature branches will not appear on the github fork and no PR can be opened. 188 + - **Branch-naming drift.** Upstream uses `feat/…` / `fix/…`. Do not invent other prefixes — their maintainers may close PRs that do not match. 189 + - **@atproto/api session expiry.** Sessions expire; cache and `agent.resumeSession` rather than logging in per request (see appendix below). 190 + - **Sync loop prevention.** When a Tangled webhook arrives, don't re-fire outbound sync. Plan an idempotency key before writing the first outbound handler. 191 + - **App password scopes.** The Tangled app password must permit PDS writes for the target collections (`sh.tangled.repo.issue`, `.comment`, `.state`, `sh.tangled.label.op`). Verify against `tangled/001-architecture-and-api.md` before generating the token. 192 + - **Biome scope.** Biome only lints JS/TS, so `flake.nix`, YAML, and markdown are safe; still run `pnpm lint` after every significant change. 193 + 194 + ## Session Progress 195 + 196 + ### Session 1 (2026-03-20) 197 + **Completed:** 198 + - Installed `@atproto/api` as a dependency in `apps/api/package.json` 199 + - Created `apps/api/src/plugins/tangled/` directory 200 + - Wrote `config.ts` — Valibot schema (`tangledConfigSchema`), inferred `TangledConfig` type, 201 + and `validateTangledConfig` async function 202 + - Wrote `index.ts` — `tangledPlugin: IntegrationPlugin` object with stubbed async handler 203 + functions for all task events (`onTaskCreated`, `onTaskStatusChanged`, `onTaskTitleChanged`, 204 + `onTaskDescriptionChanged`, `onTaskPriorityChanged`, `onTaskCommentCreated`) 205 + 206 + **Config shape (may expand later):** 207 + - `repoAtUri: string` — the AT-URI of the Tangled repo to sync with 208 + (e.g. `at://did:plc:abc123.../sh.tangled.repo/reponame`) 209 + 210 + **Concepts covered this session:** 211 + - Valibot: runtime validation library, similar to Python's pydantic. Schema = source of truth; 212 + `v.InferOutput` derives the TS type from it. Used for API input validation and plugin config validation. 213 + - `try/catch` with `v.parse()`: parse throws on failure, catch returns structured errors. 214 + - `IntegrationPlugin` interface in `plugins/types.ts` is the contract all plugins must satisfy. 215 + - Handler function naming: no collision risk since each plugin lives in its own module scope. 216 + - Prefer named function declarations over arrow functions — valid style, scales well when 217 + handlers move to their own files under `events/`. 218 + 219 + --- 220 + 221 + ### Next Session — Start Here 222 + 223 + **Step 1: Register the plugin** 224 + Find where `githubPlugin` is passed to the plugin registry and where `initializeGitHubPlugin()` 225 + is called in the main API app. Add `tangledPlugin` alongside it. 226 + Likely in `apps/api/src/index.ts` or a `plugins/index.ts` — grep for `githubPlugin` to find it. 227 + 228 + **Step 2: Add env vars** 229 + Add `TANGLED_HANDLE` and `TANGLED_APP_PASSWORD` to: 230 + - The root `.env` file 231 + - Wherever env vars are declared/typed in the API (look for `env.ts` or `config.ts` near 232 + `apps/api/src/` — grep for `GITHUB_` to find the pattern) 233 + 234 + These are the credentials used to authenticate to the user's PDS via `@atproto/api`. 235 + 236 + **Step 3: Write an atproto client helper** 237 + Once env vars are in place, create a small helper (e.g. `apps/api/src/plugins/tangled/client.ts`) 238 + that authenticates with `@atproto/api` using those env vars and exports a ready-to-use client. 239 + This will be used by all the event handlers. 240 + 241 + --- 242 + 243 + ## Working Notes 244 + 245 + - The GitHub plugin uses an **event system** (`publishEvent`) to react to Kaneo-side changes. 246 + The Tangled plugin will follow the same pattern. 247 + - Tangled webhooks will play the role that GitHub webhooks play in the existing integration. 248 + - There is no official AT Protocol TypeScript SDK maintained by Tangled; use the `@atproto/api` 249 + package (Bluesky's SDK) — it works for any PDS including Tangled's. 250 + - Watch out for sync loops: when Kaneo handles a Tangled webhook, it must not re-fire outbound sync. 251 + 252 + ## Appendix — Full Integration Context (merged from updates.md on 2026-04-18) 253 + Sections above are the current canonical plan. This appendix preserves the deeper reference material originally drafted in `updates.md`. 254 + 255 + # Kaneo × Tangled Integration — Context Document 256 + 257 + > Generated 2026-04-16. Drop this file into any Claude/Warp session for full context. 258 + 259 + --- 260 + 261 + ## 1. What Is Kaneo 262 + 263 + Self-hosted TypeScript kanban/project-management app. pnpm monorepo (Turbo). MIT license. 264 + 265 + - **Backend**: Hono (Node.js), PostgreSQL + Drizzle ORM, Better Auth, Valibot validation 266 + - **Frontend**: React 19, TanStack Router + Query, Vite, Tailwind v4, Zustand 267 + - **Upstream GitHub**: https://github.com/usekaneo/kaneo 268 + - **Your Tangled fork**: https://tangled.org/danieldaum.net/kaneo 269 + - **Dev ports**: API `1337`, web `5173` 270 + - **Package manager**: pnpm (pinned 10.28.0), Node ≥ 18 271 + 272 + Kaneo already has a fully working **two-way GitHub Issues sync** and a recently added **Gitea integration**. These are the direct models for the Tangled integration. 273 + 274 + --- 275 + 276 + ## 2. Existing Integration Architecture (your reference model) 277 + 278 + ### File layout — GitHub integration 279 + 280 + ``` 281 + apps/api/src/plugins/github/ ← event handler plugin (outbound sync) 282 + apps/api/src/github-integration/ ← Hono routes + controllers (inbound webhook + CRUD) 283 + apps/web/src/fetchers/github-integration/ 284 + apps/web/src/hooks/mutations/github-integration/ 285 + apps/web/src/hooks/queries/github-integration/ 286 + apps/web/src/components/project/github-integration-settings.tsx 287 + ``` 288 + 289 + ### Plugin system (`apps/api/src/plugins/types.ts`) 290 + 291 + All integrations implement `IntegrationPlugin`: 292 + 293 + ```ts 294 + interface IntegrationPlugin { 295 + type: string; 296 + name: string; 297 + validateConfig( 298 + config: unknown, 299 + ): Promise<{ valid: boolean; errors?: string[] }>; 300 + onTaskCreated(event: TaskCreatedEvent, ctx: PluginContext): Promise<void>; 301 + onTaskStatusChanged( 302 + event: TaskStatusChangedEvent, 303 + ctx: PluginContext, 304 + ): Promise<void>; 305 + onTaskTitleChanged( 306 + event: TaskTitleChangedEvent, 307 + ctx: PluginContext, 308 + ): Promise<void>; 309 + onTaskDescriptionChanged( 310 + event: TaskDescriptionChangedEvent, 311 + ctx: PluginContext, 312 + ): Promise<void>; 313 + onTaskPriorityChanged( 314 + event: TaskPriorityChangedEvent, 315 + ctx: PluginContext, 316 + ): Promise<void>; 317 + onTaskCommentCreated( 318 + event: TaskCommentCreatedEvent, 319 + ctx: PluginContext, 320 + ): Promise<void>; 321 + } 322 + ``` 323 + 324 + ### Database tables (already exist, reuse for Tangled) 325 + 326 + | Table | Purpose | 327 + | --------------- | ------------------------------------------------------------------------------------------ | 328 + | `integration` | Generic row per project, `type` field = `"github"` / `"gitea"` / `"tangled"` | 329 + | `external_link` | Maps `task_id → external_id (rkey)` + `integration_id`, `resource_type`, `url`, `metadata` | 330 + | `workflow_rule` | Optional: auto-move task column on external event | 331 + | `activity` | Task timeline; has `external_source`, `external_user_name`, `external_url` fields | 332 + 333 + A dedicated `github_integration` table (project_id unique FK, repo owner/name, installation_id) exists as a model — you'll want an analogous `tangled_integration` table. 334 + 335 + --- 336 + 337 + ## 3. Tangled / AT Protocol Architecture 338 + 339 + ### Two-tier model 340 + 341 + 1. **User's PDS** (AT Protocol server) — stores all forge records (issues, PRs, labels). Accessed via standard `com.atproto.repo.*` XRPC methods. This is ~80% of the work for issue sync. 342 + 2. **Knot server** — hosts actual git repos. Exposes XRPC endpoints for git operations. Requires a service auth token. **Not needed for issue sync.** 343 + 3. **AppView (tangled.org)** — server-rendered HTML only. Not an API. Used only for display URLs. 344 + 345 + ### Key principle 346 + 347 + **Creating/updating a Tangled issue = writing an atproto record to the user's PDS.** 348 + There is no Tangled REST API for this. No Tangled app registration needed. 349 + 350 + ### Auth flow (for PDS writes) 351 + 352 + ``` 353 + 1. AtpAgent.login({ identifier: TANGLED_HANDLE, password: TANGLED_APP_PASSWORD }) 354 + 2. All com.atproto.repo.* calls are now authenticated 355 + 3. No OAuth, no app installation — just handle + app password 356 + ``` 357 + 358 + Service auth tokens (`com.atproto.server.getServiceAuth`) are only needed for knot XRPC (git ops). Skip for issue sync. 359 + 360 + ### Relevant atproto collections (lexicons) 361 + 362 + | Collection | Purpose | Key fields | 363 + | ------------------------------- | ------------------ | --------------------------------------------- | 364 + | `sh.tangled.repo` | Repo metadata | `name`, `knot`, `description` | 365 + | `sh.tangled.repo.issue` | Issue record | `repo` (at-uri), `title`, `body`, `createdAt` | 366 + | `sh.tangled.repo.issue.comment` | Comment on issue | `issue` (at-uri), `body`, `createdAt` | 367 + | `sh.tangled.repo.issue.state` | State change | `issue` (at-uri), `open` (bool) | 368 + | `sh.tangled.label.op` | Apply/remove label | `issue`, `label`, `op` | 369 + | `sh.tangled.label.definition` | Label definition | `name`, `color` | 370 + 371 + ### Creating an issue (pseudocode) 372 + 373 + ```ts 374 + await agent.api.com.atproto.repo.createRecord({ 375 + repo: agent.session.did, // your DID 376 + collection: "sh.tangled.repo.issue", 377 + record: { 378 + $type: "sh.tangled.repo.issue", 379 + repo: config.repoAtUri, // "at://did:plc:xxx/sh.tangled.repo/reponame" 380 + title: task.title, 381 + body: task.description ?? "", 382 + createdAt: new Date().toISOString(), 383 + }, 384 + }); 385 + // Response: { uri, cid } — store uri/rkey in external_link.external_id 386 + ``` 387 + 388 + ### Knot discovery (not needed for issues, FYI) 389 + 390 + Read the `sh.tangled.repo` record from owner's PDS, extract `knot` field. Never hardcode knot hostnames. 391 + 392 + ### Receiving events (inbound sync) 393 + 394 + Tangled sends **webhooks** — signed HTTP POST payloads to a URL you configure in repo settings UI. 395 + 396 + - Docs: https://docs.tangled.org/webhooks 397 + - Signature: HMAC-SHA256, header `X-Tangled-Signature` (verify before processing) 398 + - Events include: `issue.created`, `issue.state`, `issue.comment.created`, etc. 399 + - Delivery retries on failure 400 + 401 + ### Best reference implementation 402 + 403 + `tangled.org/zzstoatzz/tangled-mcp` — Python MCP server doing atproto issue CRUD + knot XRPC. Study for auth and record write patterns. 404 + 405 + --- 406 + 407 + ## 4. What You've Already Built (Session 1, 2026-03-20) 408 + 409 + ### Commits on your fork 410 + 411 + - `6ae6bf57` — `feat: add atproto/api sdk` — `@atproto/api` added to `apps/api/package.json` 412 + - `383e5457` — `feat: add stubbed tangled plugin type` — scaffolded plugin, AGENTS.md added 413 + 414 + ### Files created 415 + 416 + ``` 417 + apps/api/src/plugins/tangled/config.ts ← Valibot schema, TangledConfig type, validateTangledConfig() 418 + apps/api/src/plugins/tangled/index.ts ← tangledPlugin: IntegrationPlugin (all handlers stubbed) 419 + AGENTS.md ← agent context doc (Oz/mentorship mode) 420 + ``` 421 + 422 + ### Config shape (current) 423 + 424 + ```ts 425 + // config.ts 426 + export const tangledConfigSchema = v.object({ 427 + repoAtUri: v.string(), // e.g. "at://did:plc:abc123/sh.tangled.repo/reponame" 428 + }); 429 + export type TangledConfig = v.InferOutput<typeof tangledConfigSchema>; 430 + ``` 431 + 432 + ### Plugin shape (current — all handlers are empty `{}`) 433 + 434 + ```ts 435 + export const tangledPlugin: IntegrationPlugin = { 436 + type: "tangled", 437 + name: "Tangled", 438 + onTaskCreated: handleTaskCreated, 439 + onTaskStatusChanged: handleTaskStatusChanged, 440 + onTaskPriorityChanged: handleTaskPriorityChanged, 441 + onTaskTitleChanged: handleTaskTitleChanged, 442 + onTaskDescriptionChanged: handleTaskDescriptionChanged, 443 + onTaskCommentCreated: handleTaskCommentCreated, 444 + validateConfig: validateTangledConfig, 445 + }; 446 + ``` 447 + 448 + --- 449 + 450 + ## 5. Remaining Work — Ordered Roadmap 451 + 452 + ### Phase 1 — Wire up the plugin (no auth yet) 453 + 454 + - [ ] Find where `githubPlugin` is registered — grep `githubPlugin` in `apps/api/src/index.ts` or nearby `plugins/index.ts` 455 + - [ ] Register `tangledPlugin` alongside it in the same place 456 + - [ ] Add `TANGLED_HANDLE` and `TANGLED_APP_PASSWORD` to `.env.sample` and the API's env config (grep `GITHUB_APP_ID` to find the pattern file) 457 + 458 + ### Phase 2 — atproto client helper 459 + 460 + - [ ] Create `apps/api/src/plugins/tangled/client.ts` 461 + - [ ] Use `AtpAgent` from `@atproto/api`, login with env vars, export the authenticated agent 462 + - [ ] Handle re-auth (sessions expire; catch auth errors and re-login) 463 + 464 + ### Phase 3 — Outbound sync (Kaneo → Tangled) 465 + 466 + - [ ] `handleTaskCreated`: `createRecord` → `sh.tangled.repo.issue` → store returned `uri` rkey in `external_link` 467 + - [ ] `handleTaskStatusChanged`: `createRecord` → `sh.tangled.repo.issue.state` (open/closed) 468 + - [ ] `handleTaskCommentCreated`: `createRecord` → `sh.tangled.repo.issue.comment` 469 + - [ ] `handleTaskTitleChanged` / `handleTaskDescriptionChanged`: `updateRecord` on the issue record 470 + - [ ] Status mapping: `to-do`/`in-progress` → `open: true`; `done`/`archived` → `open: false` 471 + 472 + ### Phase 4 — Database schema 473 + 474 + - [ ] Add `tangled_integration` table to `apps/api/src/database/schema.ts`: 475 + - `id` (CUID2), `project_id` (unique FK → project), `repo_at_uri`, `is_active`, `created_at`, `updated_at` 476 + - [ ] Reuse existing `integration` table (type `"tangled"`) and `external_link` table as-is 477 + - [ ] Run: `pnpm --filter @kaneo/api db:generate` 478 + 479 + ### Phase 5 — Inbound sync (Tangled → Kaneo) 480 + 481 + - [ ] Add Hono route: `POST /tangled-integration/webhook` in new `apps/api/src/tangled-integration/` 482 + - [ ] Verify `X-Tangled-Signature` HMAC before processing 483 + - [ ] Handle `issue.created` → create Kaneo task + `external_link` 484 + - [ ] Handle `issue.state` → update task status column 485 + - [ ] Handle `issue.comment.created` → add activity record with `external_source: "tangled"` 486 + - [ ] **Loop prevention**: check `external_source` flag so inbound webhook handler does not re-fire outbound plugin events 487 + 488 + ### Phase 6 — API routes (CRUD for integration config) 489 + 490 + - [ ] Mirror `apps/api/src/github-integration/` controllers: 491 + - `POST /tangled-integration` — save config (repo_at_uri), create `tangled_integration` row 492 + - `GET /tangled-integration/:projectId` — fetch config 493 + - `DELETE /tangled-integration/:projectId` — remove (cascade deletes external_links) 494 + - `POST /tangled-integration/:projectId/import` — bulk import existing Tangled issues as tasks 495 + 496 + ### Phase 7 — Frontend 497 + 498 + - [ ] `apps/web/src/fetchers/tangled-integration/` — fetch/mutate integration config 499 + - [ ] `apps/web/src/hooks/queries/tangled-integration/` + `hooks/mutations/tangled-integration/` 500 + - [ ] `apps/web/src/components/project/tangled-integration-settings.tsx` — mirrors GitHub settings component 501 + - Input: paste the repo's AT-URI (simpler than GitHub — no OAuth app install) 502 + - Toggle active/inactive 503 + - Import issues button 504 + 505 + ### Phase 8 — Edge cases 506 + 507 + - [ ] Idempotency: check `external_link` before creating duplicate records 508 + - [ ] Handle Tangled record deletions (webhook `issue.deleted` if it exists) 509 + - [ ] Label sync (optional, `sh.tangled.label.op` + `sh.tangled.label.definition`) 510 + 511 + --- 512 + 513 + ## 6. Git Workflow & Remote Setup 514 + 515 + ### Remote topology 516 + 517 + ``` 518 + usekaneo/kaneo (upstream — source of truth) 519 + ↓ manual pull via "upstream" remote 520 + local machine 521 + ├── main → push → tangled:danieldaum.net/kaneo main 522 + │ → spindle CI → github:yourusername/kaneo main 523 + └── feat/tangled-integration 524 + → push → tangled:danieldaum.net/kaneo feat/tangled-integration 525 + → spindle CI → github:yourusername/kaneo feat/tangled-integration 526 + ``` 527 + 528 + ### One-time local remote setup 529 + 530 + ```bash 531 + # Rename existing origin if needed, then add all three 532 + git remote add upstream https://github.com/usekaneo/kaneo.git 533 + git remote add tangled git@knot.danieldaum.net:danieldaum.net/kaneo 534 + git remote add github https://github.com/yourusername/kaneo.git 535 + # verify 536 + git remote -v 537 + ``` 538 + 539 + ### Pulling upstream updates (run periodically) 540 + 541 + ```bash 542 + git fetch upstream 543 + git checkout main 544 + git rebase upstream/main # keeps history clean 545 + git push tangled main # spindle CI then mirrors to github main automatically 546 + ``` 547 + 548 + ### Daily dev workflow 549 + 550 + ```bash 551 + # Keep base current before starting work 552 + git fetch upstream && git rebase upstream/main 553 + 554 + # Work on your feature branch 555 + git checkout feat/tangled-integration # create with -b if first time 556 + # ... make commits ... 557 + git push tangled feat/tangled-integration 558 + # spindle CI mirrors it to github feat/tangled-integration automatically 559 + 560 + # When ready to PR upstream: 561 + # Open PR on GitHub: yourusername/kaneo feat/tangled-integration → usekaneo/kaneo main 562 + ``` 563 + 564 + ### Spindle CI workflow (current → updated) 565 + 566 + **Current** `.tangled/workflows/mirror-to-github.yaml` only triggers on `main` and hardcodes the branch ref. This means feature branch commits are never mirrored. 567 + 568 + **Updated workflow:** 569 + 570 + ```yaml 571 + # .tangled/workflows/mirror-to-github.yaml 572 + 573 + when: 574 + - event: ["push", "manual"] 575 + branch: ["main", "feat/*"] 576 + 577 + engine: "nixery" 578 + 579 + clone: 580 + depth: 0 581 + 582 + dependencies: 583 + nixpkgs: 584 + - git 585 + 586 + steps: 587 + - name: "Mirror to GitHub" 588 + command: | 589 + git fetch --unshallow origin || true 590 + git fetch origin refs/heads/${SPINDLE_BRANCH}:refs/heads/${SPINDLE_BRANCH} 591 + git remote add github https://x-access-token:${GITHUB_PAT}@github.com/${GITHUB_USERNAME}/${GITHUB_REPO_NAME}.git 592 + git push github refs/heads/${SPINDLE_BRANCH}:refs/heads/${SPINDLE_BRANCH} --force-with-lease 593 + environment: 594 + GITHUB_USERNAME: "YOUR-GITHUB-USERNAME" 595 + GITHUB_REPO_NAME: "YOUR-GITHUB-REPO-NAME" 596 + # GITHUB_PAT must be set as a spindle secret, not here 597 + ``` 598 + 599 + **Changes from original:** 600 + 601 + - `branch: ["main", "feat/*"]` — now triggers on feature branches too 602 + - `${SPINDLE_BRANCH}` — dynamic branch name instead of hardcoded `main` 603 + - `--force-with-lease` instead of `--force` — won't overwrite GitHub commits you haven't seen 604 + - `GITHUB_PAT` stays in secrets 605 + 606 + ⚠️ **Verify the branch variable name.** `SPINDLE_BRANCH` is the expected name based on spindle's architecture but confirm it by adding `echo "Branch: ${SPINDLE_BRANCH}"` as a first step on your next run. Check `tangled.org/tangled.org/core/tree/master/spindle` if unsure. 607 + 608 + --- 609 + 610 + ## 7. Contributing Back to Upstream Kaneo 611 + 612 + ### The standard OSS flow (what CONTRIBUTING.md says) 613 + 614 + Kaneo uses the standard GitHub fork → feature branch → PR model. You do **not** need prior write access. 615 + 616 + **Step-by-step:** 617 + 618 + ```bash 619 + # 1. Fork usekaneo/kaneo on GitHub (click Fork in the UI) → creates github.com/yourusername/kaneo 620 + # 2. Clone your GitHub fork locally 621 + git clone https://github.com/yourusername/kaneo.git 622 + cd kaneo 623 + 624 + # 3. Add upstream as a remote so you can stay in sync 625 + git remote add upstream https://github.com/usekaneo/kaneo.git 626 + 627 + # 4. Create a feature branch (their convention: feat/ prefix) 628 + git checkout -b feat/tangled-integration 629 + 630 + # 5. Do your work, commit using conventional commits 631 + git commit -m "feat: add Tangled/AT Protocol two-way issue sync integration" 632 + 633 + # 6. Keep your branch up to date with upstream main 634 + git fetch upstream 635 + git rebase upstream/main 636 + 637 + # 7. Push to YOUR GitHub fork 638 + git push origin feat/tangled-integration 639 + 640 + # 8. Open a PR on GitHub from your fork's branch → usekaneo/kaneo main 641 + ``` 642 + 643 + ### Your Tangled fork vs. your GitHub fork — two separate things 644 + 645 + - `tangled.org/danieldaum.net/kaneo` — your experimental/learning fork, hosted on Tangled. This is where you develop. 646 + - `github.com/yourusername/kaneo` — the GitHub fork you create specifically to open a PR upstream. 647 + 648 + You can keep both in sync by pushing to both remotes, or just cherry-pick your Tangled commits over to the GitHub fork when ready. 649 + 650 + ### Before opening the PR 651 + 652 + - Run `pnpm lint` (Biome) — the pre-commit hook will block if this fails 653 + - Run `pnpm build` — full monorepo build check (also runs in pre-commit) 654 + - Discuss with the maintainer (Discord or a GitHub Discussion) before building the full feature — CONTRIBUTING.md says "Have an idea? Let's discuss it first" for new features 655 + - Open a GitHub Discussion or Issue titled something like "feat: Tangled/AT Protocol integration (mirrors Gitea integration)" to get a thumbs-up before submitting a large PR 656 + 657 + ### Permissions 658 + 659 + You have no write access to `usekaneo/kaneo` — that's correct and expected. The fork + PR model means you never need it. The maintainer merges your PR. 660 + 661 + ### PR description checklist (what makes a clean PR) 662 + 663 + - What: short summary of what the integration does 664 + - Why: link to Tangled, note it mirrors the existing Gitea integration pattern 665 + - How: list the files added/changed, note the new env vars required (`TANGLED_HANDLE`, `TANGLED_APP_PASSWORD`) 666 + - Testing: describe how a reviewer can test it locally (what to configure, what to observe) 667 + - i18n: add any new UI strings to `i18n/en-US.json` (their CONTRIBUTING.md requires this) 668 + 669 + --- 670 + 671 + ## 7. Key File Paths Quick Reference 672 + 673 + ``` 674 + apps/api/src/plugins/tangled/config.ts ← YOUR FILE (exists) 675 + apps/api/src/plugins/tangled/index.ts ← YOUR FILE (exists, stubbed) 676 + apps/api/src/plugins/tangled/client.ts ← TODO: atproto agent helper 677 + apps/api/src/plugins/types.ts ← IntegrationPlugin interface 678 + apps/api/src/plugins/github/ ← REFERENCE: GitHub plugin 679 + apps/api/src/github-integration/ ← REFERENCE: GitHub routes/controllers 680 + apps/api/src/database/schema.ts ← Add tangled_integration table here 681 + apps/api/src/database/relations.ts ← Add relations here 682 + apps/web/src/components/project/github-integration-settings.tsx ← REFERENCE: UI component 683 + i18n/en-US.json ← Add any new UI strings here 684 + ``` 685 + 686 + --- 687 + 688 + ## 8. Useful Commands 689 + 690 + ```bash 691 + # Start dev environment 692 + pnpm dev 693 + 694 + # After schema changes 695 + pnpm --filter @kaneo/api db:generate 696 + # (migrations auto-run on API startup) 697 + 698 + # Lint/format (required before commit) 699 + pnpm lint 700 + 701 + # Full build (also runs in pre-commit hook) 702 + pnpm build 703 + 704 + # Filter to one package 705 + pnpm --filter @kaneo/api dev 706 + pnpm --filter @kaneo/web dev 707 + ``` 708 + 709 + --- 710 + 711 + ## 9. Known Pitfalls & Hard Problems 712 + 713 + ### 1. Sync loop prevention ⚠️ (highest risk) 714 + 715 + When Tangled fires a webhook → Kaneo updates a task → the plugin event system fires → writes back to Tangled → another webhook fires → infinite loop. 716 + 717 + The GitHub integration has a guard for this somewhere. **Before writing any inbound webhook handler, find it first.** Grep for `external_source` or look for a flag/context field passed through `publishEvent`. The pattern is likely: if the task update was triggered by an inbound webhook, skip outbound plugin dispatch (or check `external_link` to detect it's already synced). 718 + 719 + Mitigation strategies: 720 + 721 + - Pass a `syncSource: "tangled"` flag through the event context; have the plugin no-op if it sees its own source 722 + - Use a short-lived in-memory set of "currently syncing" task IDs and skip outbound if the task is in it 723 + - Check the `external_source` field on the activity record before dispatching 724 + 725 + **Do not ship inbound sync without this solved.** 726 + 727 + --- 728 + 729 + ### 2. atproto session expiry (silent failure risk) 730 + 731 + `AtpAgent` sessions expire. In a long-running Hono process, the agent will eventually return auth errors on every call and the integration will silently stop working — no crash, no obvious log. 732 + 733 + Plan: 734 + 735 + - Wrap every `createRecord`/`updateRecord` call in a helper that catches `401`/`AuthenticationRequired` errors, calls `agent.login()` again, and retries once 736 + - The `@atproto/api` agent has a `session` property and a `resumeSession()` method — read the SDK source before rolling your own 737 + - Consider storing the session in a module-level singleton so re-auth doesn't require a full cold login on every request 738 + 739 + --- 740 + 741 + ### 3. `repoAtUri` is hostile UX 742 + 743 + Asking a user to paste `at://did:plc:abc123.../sh.tangled.repo/reponame` directly is a bad experience and error-prone. The GitHub integration avoids this with an OAuth install flow. 744 + 745 + Options (pick one for the UI): 746 + 747 + - Accept a friendly `owner.handle/repo-name` format and resolve it to a DID + AT-URI server-side on save 748 + - Make the settings page do a PDS lookup: given a handle, resolve the DID via `com.atproto.identity.resolveHandle`, then list their `sh.tangled.repo` records, and let the user pick from a dropdown 749 + - Minimum viable: accept the raw AT-URI but show a helper link to tangled.org where they can copy it 750 + 751 + The second option is the best UX but requires two extra API calls. Implement the raw paste version first, improve later. 752 + 753 + --- 754 + 755 + ### 4. Issue state is a separate record (not a field) 756 + 757 + Tangled issue state (`open`/`closed`) is stored as a distinct `sh.tangled.repo.issue.state` record — it is **not** a field on the issue itself. This has two consequences: 758 + 759 + - **On import**: to know if an existing Tangled issue is open or closed, you must query `sh.tangled.repo.issue.state` records separately and join them by issue AT-URI. You can't just read the issue record. 760 + - **On status sync**: `handleTaskStatusChanged` must write a new `sh.tangled.repo.issue.state` record, not update the issue record. Multiple state records can exist; the most recent one wins. 761 + 762 + The `external_link` table's `metadata` JSON field is a good place to cache the last-known state to avoid extra PDS round-trips on every read. 763 + 764 + --- 765 + 766 + ### 5. Local webhook development requires a public URL 767 + 768 + Tangled webhooks need an HTTPS URL it can POST to. `localhost:1337` will not work. 769 + 770 + You'll need one of: 771 + 772 + - `ngrok http 1337` — creates a temporary public tunnel, free tier sufficient for dev 773 + - `cloudflared tunnel` — Cloudflare's equivalent, also free 774 + - Deploy to a staging environment for inbound sync testing 775 + 776 + Set this up before starting Phase 5 or you won't be able to test inbound sync at all. Add a note in your `.env.sample`: `WEBHOOK_BASE_URL=https://your-ngrok-url.ngrok.io`. 777 + 778 + --- 779 + 780 + ## 10. External References 781 + 782 + | Resource | URL | 783 + | ---------------------------- | ---------------------------------------------------------- | 784 + | Kaneo upstream | https://github.com/usekaneo/kaneo | 785 + | Your Tangled fork | https://tangled.org/danieldaum.net/kaneo | 786 + | Tangled docs | https://docs.tangled.org | 787 + | Tangled webhooks | https://docs.tangled.org/webhooks | 788 + | Tangled core monorepo | https://tangled.org/tangled.org/core | 789 + | DeepWiki: GitHub integration | https://deepwiki.com/usekaneo/kaneo/5.4-github-integration | 790 + | Reference MCP impl (Python) | https://tangled.org/zzstoatzz/tangled-mcp | 791 + | @atproto/api npm | https://www.npmjs.com/package/@atproto/api | 792 + | Kaneo Discord | https://discord.gg/rU4tSyhXXU |