Compare commits

...
33 Commits
Author SHA1 Message Date
skrueckenandCursor 846bb31dc9 Add stdio MCP server for token-scoped ticket access.
CI / test-and-build (push) Failing after 1m25s
CI / demo (push) Skipped
Expose tm mcp tools against the local daemon, document agent setup, and
ignore workspace .tm_token files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 16:56:11 +01:00
skrueckenandCursor 2dbf0918de Add local JSON daemon and route TUI/CLI through it.
A per-DataDir background process owns ticket JSON, history, and sync so
multiple TUI instances stay consistent; MCP token APIs are ready for phase 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 16:32:13 +01:00
skrueckenandCursor 98fcb57298 Align the TUI help legend into a fixed-width column grid.
CI / test-and-build (push) Failing after 1m31s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:17:44 +01:00
skrueckenandCursor 229f7a2fe1 Document TUI/web features, sync backends, and server env in the README.
CI / test-and-build (push) Failing after 1m22s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:14:15 +01:00
skrueckenandCursor 69c6747a31 Keep board tickets sized to their content instead of stretching with taller columns.
CI / test-and-build (push) Failing after 1m32s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:11:41 +01:00
skrueckenandCursor efeb4a74a8 Assign readable ticket keys from project code and sequence.
Replace UUID-looking sync labels in TUI and web with keys like TAS-1,
while keeping the remote UUID for task-master matching.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 00:09:14 +01:00
skrueckenandCursor 26a3723a06 Support ODS, XLS, and other spreadsheet formats in the attachment editor.
Detect editable attachments by extension and write back in the original book type instead of always forcing xlsx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:56:14 +01:00
skrueckenandCursor 6e6b13270a Add ticket file attachments with simple in-browser Excel editing.
Store uploads on disk under DataDir and expose them in the web ticket view so .xlsx files can be previewed and saved as cell values without YouTrack or TUI sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:53:17 +01:00
skrueckenandCursor 614cb94b46 Add visual and Markdown editors for ticket descriptions.
Replace the plain textarea with a dual-mode editor so ticket content can be written as WYSIWYG or Markdown while still storing Markdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:40:55 +01:00
skrueckenandCursor f97856b8a6 Allow dragging tickets between OPEN, WIP, and DONE on the project board.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:29:01 +01:00
skrueckenandCursor 34ab3b9c5b Add date and ticket filters plus entry list to the project time report.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:23:22 +01:00
skrueckenandCursor 2061df63d4 Restyle web buttons with borderless square tabs and accent gradients.
CI / test-and-build (push) Failing after 1m24s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:18:07 +01:00
skrueckenandCursor a594183310 Persist web navigation across reloads via hash routes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:09:17 +01:00
skrueckenandCursor b311a355fd Wire demo seed so TUI connects to Test and Website Relaunch.
Dev seed now issues write tokens and writes local taskmaster meta under TASK_MASTER_HOME, so make run-dev can sync without manual setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:05:10 +01:00
skrueckenandCursor 79b0821837 Allow editing time entry start and end in TUI and web.
Duration is derived from the range so timer sessions can be corrected after the fact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:59:40 +01:00
skrueckenandCursor afbf86dcf2 Stop tracking embedded Vite build output.
CI / test-and-build (push) Failing after 1m33s
CI / demo (push) Skipped
Keep a .gitkeep placeholder for go:embed and rebuild the frontend via make web / run-server-dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:50:08 +01:00
skrueckenandCursor 925bedf259 Use full-width layout for the web shell and topbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:49:46 +01:00
skrueckenandCursor 472a2fa72e Add global web nav with project groups and tree sidebar.
Restructure the UI into top-level tabs and a scrollable project tree, and persist optional project groups so demos and settings can organize many projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:43:03 +01:00
skrueckenandCursor 74066649e4 Submit the login form on Enter in the password field.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:25:59 +01:00
skrueckenandCursor 4adfcdb95a Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:23:11 +01:00
skrueckenandCursor 98d2253f44 Add YouTrack pull/push sync with project settings and time tracking.
CI / test-and-build (push) Failing after 1m19s
CI / demo (push) Skipped
Wire manual Y menu and CLI sync for issues and work items, persist
per-project credentials, and keep local edits until an explicit push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 00:17:51 +02:00
skrueckenandCursor 19ae1f1b62 Install Node in CI containers so checkout post-steps can run.
CI / test-and-build (push) Failing after 1m21s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 19:41:48 +02:00
skrueckenandCursor 214ae1e0cf Add README and Gitea CI with demo artifact upload.
CI / test-and-build (push) Failing after 54s
CI / demo (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 19:08:32 +02:00
SkrueckenandCursor 81f2902ff1 Demo interactive checklist toggles in the VHS walkthrough.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 15:08:21 +02:00
SkrueckenandCursor f08084d5e1 Add interactive Markdown checklist toggles in the ticket preview.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 14:37:14 +02:00
SkrueckenandCursor 7666715f6c Allow moving tickets between projects with P.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 14:19:10 +02:00
SkrueckenandCursor c5920ffd54 Allow deleting projects from the project picker with x/X.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 13:59:20 +02:00
SkrueckenandCursor f3a592f3ea Ship as tm with install to GOPATH/bin and sandbox env overrides.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 13:55:54 +02:00
SkrueckenandCursor 2dcab9e4d8 Demo ticket editing in vim with rendered markdown on the card.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 13:52:20 +02:00
SkrueckenandCursor 21a0d2ad51 Add a VHS tape that demos the full task-master feature set.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 11:19:52 +02:00
SkrueckenandCursor bfc1cbd4d9 Add ticket time tracking with reports and entry management.
Support start/stop timer, manual durations, grouped reports, and per-ticket edit/delete of time entries with undo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 11:05:10 +02:00
SkrueckenandCursor 41feba11de Add multiselect board filters and letter-jump on picker menus.
Persist comma-separated list filters per project, toggle with space, and jump by first letter in board and project pickers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 10:42:59 +02:00
SkrueckenandCursor aa8f89b43a Jump to status options with their first-letter shortcuts.
o/w/d select OPEN/WIP/DONE in the status menu, with the hotkeys shown inline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 10:38:28 +02:00
158 changed files with 20652 additions and 239 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"tm": {
"command": "${workspaceFolder}/bin/tm",
"args": ["mcp"]
}
}
}
+81
View File
@@ -0,0 +1,81 @@
name: CI
on: [push, pull_request]
jobs:
test-and-build:
runs-on: ubuntu-latest
container:
image: golang:latest
options: --network host
steps:
# Node-Actions (checkout / upload-artifact) brauchen node im Container
- name: Install Node.js
run: |
apt-get update
apt-get install -y --no-install-recommends nodejs npm
- name: Check out repository code
uses: actions/checkout@v4
- name: List files in the repository
run: ls ${{ gitea.workspace }}
- name: Download modules
run: go mod download
- name: Test
run: make test
- name: Build
run: make build
- name: Build server
run: make build-server
- name: Version
run: make version
- name: Upload binary
uses: actions/upload-artifact@v3
with:
name: tm
path: |
./bin/tm
./bin/tm_server
- run: echo "This job's status is ${{ job.status }}."
demo:
runs-on: ubuntu-latest
needs: test-and-build
container:
image: ghcr.io/charmbracelet/vhs:latest
options: --network host
steps:
- name: Install Node.js and vim
run: |
apt-get update
apt-get install -y --no-install-recommends nodejs npm vim
- name: Check out repository code
uses: actions/checkout@v4
- name: Download binary
uses: actions/download-artifact@v3
with:
name: tm
path: ./bin
- name: Make binary executable
run: chmod +x ./bin/tm
- name: Record demo
run: vhs demo.tape
- name: Upload demo
uses: actions/upload-artifact@v3
with:
name: demo
path: |
demo.gif
demo.mp4
+15
View File
@@ -1,4 +1,19 @@
/bin/
.dev-home/
.tm_token
**/.tm_token
*.exe
*.test
*.out
# Generated by `make demo` / CI — commit demo.gif so the README preview works on Gitea
demo.mp4
demo.webm
web/node_modules/
web/dist/
# Vite output embedded via go:embed — rebuild with `make web` / `make build-server`
internal/webembed/dist/**
!internal/webembed/dist/.gitkeep
*.db
*.db-wal
*.db-shm
+30
View File
@@ -0,0 +1,30 @@
# Agent notes — task-master
## Tickets über MCP
Tickets lesen und bearbeiten nur über den lokalen MCP-Server von task-master — nicht über JSON-Dateien oder die TUI.
### Setup
1. Binary: `./bin/tm` (oder `tm` im PATH)
2. Projekt-MCP: [`.cursor/mcp.json`](.cursor/mcp.json) (`tm` → `args: ["mcp"]`)
3. Im Repo (oder darüber) `.tm_token` mit dem Projekt, für das die TUI ein Token erzeugt hat (`p` → Projekt wählen → `g`):
```
project=<projekt-id>
token=tm_mcp_…
```
`.tm_token` nicht committen.
### Verhalten
- Ohne gültiges `.tm_token` darf der MCP-Server den Daemon nicht nutzen.
- Mit Token sind alle Tools auf genau dieses eine TM-Projekt beschränkt.
- TUI/`tm sync` brauchen kein Token; die KI schon.
### Typische Tools
`list_projects`, `list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, `move_ticket`, `delete_ticket`, `list_time`, `add_time`, `undo`, `redo`, `sync_pull`, `sync_push`
Mehr Details: [README.md](README.md) (Abschnitt MCP).
+68 -6
View File
@@ -1,16 +1,78 @@
BINARY := task-master
CMD := ./cmd/task-master
VERSION_PKG := task/internal/version
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
BINARY := tm
BINARY_SERVER := tm_server
CMD := ./cmd/task-master
CMD_SERVER := ./cmd/tm-server
VERSION_PKG := task/internal/version
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
.PHONY: build run version
# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=...
GOPATH ?= $(shell go env GOPATH)
PREFIX ?= $(GOPATH)
INSTALL_BIN := $(PREFIX)/bin
DEV_HOME := $(CURDIR)/.dev-home
.PHONY: build build-server build-all web web-build run run-dev run-server-dev install uninstall test version demo
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
web:
cd web && npm ci && npm run build
@mkdir -p internal/webembed/dist && touch internal/webembed/dist/.gitkeep
# Rebuild frontend only (assumes web/node_modules already installed).
web-build:
cd web && npm run build
@mkdir -p internal/webembed/dist && touch internal/webembed/dist/.gitkeep
build-server: web
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
# Build server binary without rebuilding frontend (uses local webembed/dist).
build-server-go:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
build-all: build build-server
run: build
./bin/$(BINARY)
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config).
# YouTrack is not part of the local test environment — use tm_server instead.
run-dev: build
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
# Web test environment: SQLite demo against tm_server only (no YouTrack).
# Seeds 50 demo projects; wires TUI projects Test + Website Relaunch with write tokens.
# Login admin/admin (alice/alice as member). Then: make run-dev → Y pull.
run-server-dev: web-build build-server-go
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \
$(DEV_HOME)/.local/share/task-master/task-master.db-shm
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack \
"$(DEV_HOME)/.local/share/task-master/projects/Test" \
"$(DEV_HOME)/.local/share/task-master/projects/Website Relaunch"
TASK_MASTER_HOME=$(DEV_HOME) TM_ADMIN_USER=admin TM_ADMIN_PASSWORD=admin \
TM_SEED_DEMO=1 TASK_MASTER_SECRET=dev-secret \
./bin/$(BINARY_SERVER) -listen :8080
install: build
install -d $(INSTALL_BIN)
install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY)
@echo "installed $(INSTALL_BIN)/$(BINARY)"
uninstall:
rm -f $(INSTALL_BIN)/$(BINARY)
@echo "removed $(INSTALL_BIN)/$(BINARY)"
test:
go test ./...
version: build
./bin/$(BINARY) version
# Record a full feature walkthrough (requires vhs: https://github.com/charmbracelet/vhs)
demo:
vhs demo.tape
+189
View File
@@ -0,0 +1,189 @@
# task-master
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea). Dazu der optionale Web-Server `tm_server` mit Svelte-UI und SQLite.
![Demo](demo.gif)
> Demo wird aus [`demo.tape`](demo.tape) erzeugt (`make demo`). Ohne GIF lokal: siehe [Demo aufnehmen](#demo-aufnehmen).
## Features
**TUI (`tm`)**
- Projekte & Tickets lokal als JSON hinter einem Hintergrund-Daemon (Unix-Socket)
- Mehrere TUI-Instanzen teilen denselben State; `tm stop` beendet den Daemon
- Board-Filter: All / Backlog / In Progress / Done
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
- Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren
- Report mit Group-by (Ticket / Tag)
- Undo / Redo über eine Command-History (im Daemon)
- Vim-Style Keybindings
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
- Sync gegen `tm_server` (Backend `taskmaster`, Projekt-Tokens)
- MCP-Token pro Projekt erzeugen (`g` in der Projektliste) für späteren KI-Zugriff
**Web (`tm_server`)**
- Eingebettetes Svelte-UI, SQLite, Auth und Rechte pro Projekt
- Projektgruppen, Sidebar-Navigation, Hash-Routes
- Drag & Drop auf dem Board (OPEN / WIP / DONE)
- Lesbare Ticket-Keys aus Projektcode + Sequenz (z. B. `WEB-12`)
- Visual- und Markdown-Editor für Ticket-Beschreibungen
- Datei-Anhänge; Tabellen (XLSX/ODS/…) im Browser editierbar
- Report mit Datums- und Ticket-Filtern sowie Eintragsliste
- Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
- Members, Settings (Tokens), Users (Admin)
## Install
Voraussetzung: Go (siehe `go.mod`). Für `tm_server` zusätzlich Node.js (Frontend-Build).
```bash
git clone git@git.aloxdrim.de:skruecken/task-master.git
cd task-master
make install # → $(go env GOPATH)/bin/tm
# oder nur bauen:
make build # → ./bin/tm
make build-server # Frontend + → ./bin/tm_server
make build-all # beides
```
## Nutzung
```bash
tm # TUI starten (startet Daemon bei Bedarf)
tm sync pull # Issues von YouTrack / tm_server holen
tm sync push # lokale Änderungen pushen
tm sync pull -p inbox # anderes Projekt
tm status # Daemon-Status + Socket-Pfad
tm stop # Hintergrund-Daemon beenden
tm daemon # Daemon im Vordergrund (Debug)
tm version # Build-Version (Git-Hash)
tm help
```
### Lokaler JSON-Daemon
`tm` speichert weiterhin JSON unter dem DataDir, schreibt aber nur noch über einen lokalen Daemon-Prozess (eine Instanz pro DataDir). Socket/PID liegen unter `$XDG_RUNTIME_DIR/task-master-<hash>/` (Fallback: Temp-Dir). Die TUI und `tm sync` verbinden sich als vertrauenswürdige Clients (`X-TM-Client: tui|cli`).
### MCP (`tm mcp`)
1. In der TUI-Projektliste `g` → Token erzeugen (Klartext einmal in der Statuszeile); `G` widerruft.
2. Im Repo-Root (oder darüber) `.tm_token` anlegen — **nicht committen**:
```
project=inbox
token=tm_mcp_…
```
3. Cursor / MCP-Client:
```json
{ "command": "tm", "args": ["mcp"] }
```
Ohne gültiges `.tm_token` macht der MCP-Server keine Daemon-Aufrufe. Mit Token sind Lesen und Schreiben nur für dieses eine TM-Projekt erlaubt.
### Web-Server (`tm_server`)
```bash
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekte + verdrahtet TUI
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
make run-dev # TUI: Projekte „Test“ + „Website Relaunch“ schon mit Token connected
# Produktion / manuell:
export TM_ADMIN_USER=admin
export TM_ADMIN_PASSWORD='change-me'
export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
./bin/tm_server -listen :8080
# JSON-Daten der TUI einmalig importieren:
./bin/tm_server import-json -owner admin
```
UI: http://localhost:8080 — Dashboard, Board, Report, Attachments, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
Beim Seed (`TM_SEED_DEMO=1`) werden Demo-Projekte angelegt und für **Test** sowie **Website Relaunch** Write-Tokens unter `.dev-home` als lokale TUI-Projekte (`backend=taskmaster`) hinterlegt — danach `make run-dev` und Sync (`Y` → Pull). Login: `admin`/`admin` (Member: `alice`/`alice`).
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`. Frontend neu bauen ohne Go-Rebuild: `make web-build`.
Sandbox ohne echte Config/Daten:
```bash
make run-dev # nutzt ./.dev-home
# oder:
TASK_MASTER_HOME=/tmp/tm-sandbox tm
```
Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~/.config/task-master`.
| Variable | Wirkung |
|------------------------|---------------------------------------------------|
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
| `TASK_MASTER_DATA_DIR` | Nur Datenverzeichnis überschreiben |
| `TASK_MASTER_SECRET` | Verschlüsselt Remote-Tokens in SQLite |
| `TM_ADMIN_USER` / `TM_ADMIN_PASSWORD` | Bootstrap des ersten Admins |
| `TM_SEED_DEMO=1` | Demo-Projekte + TUI-Verdrahtung (Test / Website Relaunch) |
| `TM_SEED_BASE_URL` | Basis-URL in den geseedeten TUI-`meta.json` |
Server-Flags: `-listen`, `-db`, `-secure` (siehe `tm_server help`).
## Keybindings (Auszug)
| Taste | Aktion |
|---------|------------------------|
| `j`/`k` | Navigieren |
| `n` | Neues Ticket/Projekt |
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack / Sync-Backend) |
| `s` | Status ändern |
| `l` | Board-Filter |
| `/` | Suche |
| `p`/`P` | Projekt / Ticket verschieben |
| `Y` | Sync-Menü: Pull / Push |
| `t`/`T` | Timer / Zeit buchen |
| `m` | Zeiteinträge |
| `r` | Report |
| `x`/`X` | Löschen / Force-Delete |
| `u`/`U` | Undo / Redo |
| `q` | Beenden |
Checklisten im Preview: `J`/`K` + `Space`.
### YouTrack / Sync
1. `p` → Projekt wählen → `e`
2. Backend wählen: YouTrack (Remote-URL, Permanent Token, Project-Key) oder task-master (Server-URL + Projekt-Token)
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
4. `Y` → **Pull** oder **Push**
CLI: `tm sync pull` / `tm sync push`
Zeit: lokale Einträge ohne `externalId` werden remote als Work-Items gepusht; remote Work Items landen lokal beim Pull.
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
## Entwicklung
```bash
make test
make build
make build-server # braucht Node (npm ci + Vite-Build)
make version
```
CI (Gitea): Tests, `tm` + `tm_server`-Build, Demo-GIF als Artifact.
## Demo aufnehmen
Die Walkthrough-Aufnahme steckt in [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)):
```bash
# einmalig: vhs + ttyd/ffmpeg (siehe VHS-Docs)
make demo # schreibt demo.gif (+ demo.mp4)
```
Die Pipeline erzeugt das GIF ebenfalls und lädt es als Artifact hoch.
## Lizenz
Privat / eigener Use — siehe Repository-Einstellungen auf Gitea.
+213 -18
View File
@@ -1,15 +1,18 @@
package main
import (
"context"
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"task/internal/command"
"task/internal/config"
"task/internal/project"
"task/internal/ticket"
"task/internal/daemon"
"task/internal/daemon/api"
"task/internal/daemon/client"
mcpserver "task/internal/mcp"
"task/internal/ui"
"task/internal/version"
)
@@ -18,11 +21,41 @@ func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version", "-v":
fmt.Printf("%s %s\n", config.AppName, version.BuildVersion)
fmt.Printf("%s %s\n", config.CliName, version.BuildVersion)
return
case "help", "--help", "-h":
printHelp()
return
case "sync":
if err := runSync(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
case "daemon":
if err := runDaemon(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
case "stop":
if err := runStop(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
case "status":
if err := runStatus(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
case "mcp":
if err := runMCP(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printHelp()
@@ -36,37 +69,199 @@ func main() {
}
}
func run() error {
func loadConfig() (*config.Config, error) {
cfg, err := config.LoadOrCreate()
if err != nil {
return fmt.Errorf("config: %w", err)
return nil, fmt.Errorf("config: %w", err)
}
abs, err := daemon.AbsoluteDataDir(cfg.DataDir)
if err != nil {
return nil, err
}
cfg.DataDir = abs
return cfg, nil
}
store := ticket.NewJSONStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
if err := projects.Ensure(cfg.ActiveProject); err != nil {
func ensureClient(kind string) (*config.Config, *client.Client, error) {
cfg, err := loadConfig()
if err != nil {
return nil, nil, err
}
paths, err := daemon.Ensure(cfg)
if err != nil {
return nil, nil, fmt.Errorf("daemon: %w", err)
}
cli := client.Dial(client.Options{Socket: paths.Socket, Kind: kind})
return cfg, cli, nil
}
func run() error {
cfg, cli, err := ensureClient(api.ClientTUI)
if err != nil {
return err
}
if err := cli.ProjectStore().Ensure(cfg.ActiveProject); err != nil {
return fmt.Errorf("ensure project: %w", err)
}
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store)
if err != nil {
return fmt.Errorf("history: %w", err)
}
model := ui.New(cfg, store, projects, history)
model := ui.New(cfg, cli.TicketStore(), cli.ProjectStore(), cli.TimeStore(), cli.Executor(), cli)
prog := tea.NewProgram(model, tea.WithAltScreen())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_ = cli.SubscribeEvents(ctx, func(ev api.Event) {
prog.Send(ui.DaemonChangedMsg{ProjectID: ev.ProjectID})
})
}()
if _, err := prog.Run(); err != nil {
return fmt.Errorf("tui: %w", err)
}
return nil
}
func runDaemon() error {
cfg, err := loadConfig()
if err != nil {
return err
}
return daemon.Run(cfg)
}
func runStop() error {
cfg, err := loadConfig()
if err != nil {
return err
}
if err := daemon.Stop(cfg); err != nil {
return err
}
fmt.Println("daemon stopped")
return nil
}
func runStatus() error {
cfg, err := loadConfig()
if err != nil {
return err
}
st, err := daemon.StatusOf(cfg)
if err != nil {
return err
}
state := "stopped"
if st.Running {
state = "running"
}
fmt.Printf("status: %s\n", state)
fmt.Printf("socket: %s\n", st.Socket)
fmt.Printf("dataDir: %s\n", st.DataDir)
if st.PID > 0 {
fmt.Printf("pid: %d\n", st.PID)
}
return nil
}
func runSync(args []string) error {
cfg, cli, err := ensureClient(api.ClientCLI)
if err != nil {
return err
}
projectID := cfg.ActiveProject
action := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "pull", "push":
if action != "" {
return fmt.Errorf("specify only one of pull/push")
}
action = args[i]
case "--project", "-p":
if i+1 >= len(args) {
return fmt.Errorf("%s requires a project id", args[i])
}
i++
projectID = strings.TrimSpace(args[i])
case "--help", "-h":
fmt.Printf("Usage: %s sync <pull|push> [--project ID]\n", config.CliName)
return nil
default:
return fmt.Errorf("unknown sync argument: %s (use pull or push)", args[i])
}
}
if action == "" {
return fmt.Errorf("usage: %s sync <pull|push> [--project ID]", config.CliName)
}
if projectID == "" {
return fmt.Errorf("project id required")
}
if action == "push" {
out, err := cli.SyncPush(projectID)
if err != nil {
return err
}
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
projectID, out.Created, out.Updated, out.TimePushed)
return nil
}
out, err := cli.SyncPull(projectID)
if err != nil {
return err
}
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
projectID, out.Created, out.Updated, out.TimePulled, out.Total)
return nil
}
func runMCP() error {
cwd, err := os.Getwd()
if err != nil {
return err
}
tf, err := mcpserver.FindTokenFile(cwd)
if err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
paths, err := daemon.Ensure(cfg)
if err != nil {
return fmt.Errorf("daemon: %w", err)
}
cli := client.Dial(client.Options{
Socket: paths.Socket,
Kind: api.ClientMCP,
Token: tf.Token,
Project: tf.Project,
})
return mcpserver.ServeStdio(cli, tf)
}
func printHelp() {
fmt.Printf(`%s — offline-first terminal task manager
Usage:
%s Start the TUI
%s version Print build version
%s help Show this help
%s Start the TUI (starts local daemon if needed)
%s sync pull [-p ID] Pull via daemon from configured backend
%s sync push [-p ID] Push via daemon to configured backend
%s daemon Run the JSON daemon in the foreground
%s stop Stop the background daemon
%s status Show daemon status and socket path
%s mcp MCP stdio server (requires .tm_token)
%s version Print build version
%s help Show this help
`, config.AppName, config.AppName, config.AppName, config.AppName)
Env:
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
TASK_MASTER_DATA_DIR Override data directory only
The TUI talks to a per-DataDir background daemon over a Unix socket so multiple
instances stay consistent. Create an MCP token from the project list (key g);
place project= and token= in a repo .tm_token file, then run: tm mcp
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
}
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"task/internal/config"
"task/internal/server"
"task/internal/sqlite"
"task/internal/version"
)
func main() {
listen := flag.String("listen", ":8080", "HTTP listen address")
dbPath := flag.String("db", "", "SQLite database path (default: <dataDir>/task-master.db)")
secure := flag.Bool("secure", false, "Set Secure flag on session cookies")
flag.Parse()
args := flag.Args()
if len(args) > 0 {
switch args[0] {
case "version", "--version", "-v":
fmt.Printf("tm_server %s\n", version.BuildVersion)
return
case "help", "--help", "-h":
printHelp()
return
case "import-json":
if err := runImportJSON(args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
printHelp()
os.Exit(1)
}
}
if err := runServer(*listen, *dbPath, *secure); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func openDB(dbPath string) (*sqlite.DB, *config.Config, error) {
cfg, err := config.LoadOrCreate()
if err != nil {
return nil, nil, fmt.Errorf("config: %w", err)
}
path := dbPath
if path == "" {
path = filepath.Join(cfg.DataDir, "task-master.db")
}
secret := []byte(os.Getenv("TASK_MASTER_SECRET"))
db, err := sqlite.Open(path, secret)
if err != nil {
return nil, nil, err
}
return db, cfg, nil
}
func bootstrapAdmin(db *sqlite.DB) error {
n, err := db.CountUsers()
if err != nil {
return err
}
if n > 0 {
return nil
}
user := strings.TrimSpace(os.Getenv("TM_ADMIN_USER"))
pass := os.Getenv("TM_ADMIN_PASSWORD")
if user == "" || pass == "" {
fmt.Fprintln(os.Stderr, "no users in database; set TM_ADMIN_USER and TM_ADMIN_PASSWORD to create the first admin")
return nil
}
u, err := db.CreateUser(user, pass, true)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "created bootstrap admin %q (%s)\n", u.Username, u.ID)
return nil
}
func runServer(listen, dbPath string, secure bool) error {
db, cfg, err := openDB(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := bootstrapAdmin(db); err != nil {
return err
}
srv := server.New(db)
srv.DataDir = cfg.DataDir
srv.Secure = secure
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
if err := srv.SeedDemo(server.SeedDemoOptions{
ClientDataDir: cfg.DataDir,
BaseURL: server.SeedBaseURL(listen),
}); err != nil {
return fmt.Errorf("seed: %w", err)
}
projects, _ := srv.Projects.List()
fmt.Fprintf(os.Stderr, "demo seed ready (%d projects; login admin/admin, alice/alice)\n", len(projects))
}
fmt.Fprintf(os.Stderr, "tm_server %s listening on %s\n", version.BuildVersion, listen)
return http.ListenAndServe(listen, srv.Handler())
}
func runImportJSON(args []string) error {
fs := flag.NewFlagSet("import-json", flag.ExitOnError)
dataDir := fs.String("data", "", "JSON DataDir to import (default from config)")
dbPath := fs.String("db", "", "SQLite database path")
owner := fs.String("owner", "", "username to own imported projects")
_ = fs.Parse(args)
db, cfg, err := openDB(*dbPath)
if err != nil {
return err
}
defer db.Close()
if err := bootstrapAdmin(db); err != nil {
return err
}
dir := *dataDir
if dir == "" {
dir = cfg.DataDir
}
var ownerID string
if *owner != "" {
u, err := db.GetUserByUsername(*owner)
if err != nil {
return fmt.Errorf("owner: %w", err)
}
ownerID = u.ID
} else {
users, err := db.ListUsers()
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("no users; create admin first (TM_ADMIN_USER/TM_ADMIN_PASSWORD)")
}
for _, u := range users {
if u.IsAdmin {
ownerID = u.ID
break
}
}
if ownerID == "" {
ownerID = users[0].ID
}
}
srv := server.New(db)
fmt.Fprintf(os.Stderr, "importing from %s …\n", dir)
if err := srv.ImportJSONDataDir(dir, ownerID); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "import done")
return nil
}
func printHelp() {
fmt.Print(`tm_server — task-master web server
Usage:
tm_server [flags]
tm_server import-json [-data DIR] [-db PATH] [-owner USER]
tm_server version
tm_server help
Flags:
-listen ADDR HTTP listen address (default :8080)
-db PATH SQLite path (default <dataDir>/task-master.db)
-secure Set Secure cookie flag
Env:
TASK_MASTER_HOME / TASK_MASTER_DATA_DIR same as tm
TASK_MASTER_SECRET encrypts remote tokens
TM_ADMIN_USER / TM_ADMIN_PASSWORD bootstrap first admin
TM_SEED_DEMO=1 seed demo projects + wire TUI (Test, Website Relaunch)
TM_SEED_BASE_URL override base URL written into TUI meta.json
`)
}
+246
View File
@@ -0,0 +1,246 @@
# task-master feature demo
# Run from repo root: make demo (or: vhs demo.tape)
Output demo.gif
Output demo.mp4
Require go
Require vim
Set Shell "bash"
Set FontSize 14
Set Width 1280
Set Height 820
Set Theme "Catppuccin Mocha"
Set TypingSpeed 35ms
Set PlaybackSpeed 1.0
Set Padding 12
Set Framerate 30
# Isolated config/data so the demo never touches your real ~/.config
Env TASK_MASTER_HOME "/tmp/task-master-vhs"
Env TERM "xterm-256color"
Env EDITOR "vim"
# --- setup (hidden) ----------------------------------------------------------
Hide
Type "rm -rf /tmp/task-master-vhs && mkdir -p /tmp/task-master-vhs"
Enter
Sleep 300ms
Type "test -x ./bin/tm || make build"
Enter
Sleep 5s
Show
# --- launch ------------------------------------------------------------------
Type "./bin/tm"
Enter
Sleep 1s
# --- create tickets ----------------------------------------------------------
Type "n"
Sleep 400ms
Type "Design landing page"
Enter
Sleep 800ms
Type "n"
Sleep 400ms
Type "Wire up auth"
Enter
Sleep 800ms
Type "n"
Sleep 400ms
Type "Ship MVP checklist"
Enter
Sleep 1s
# --- navigate to first ticket ------------------------------------------------
Type "k"
Sleep 400ms
Type "k"
Sleep 800ms
# --- edit ticket in vim (markdown + checklist) -------------------------------
Type "e"
Sleep 1s
# File opens as "# Design landing page" — append a markdown description
Type "Go"
Sleep 300ms
Enter
Type "## Goals"
Enter
Type "- [ ] Hero section with brand"
Enter
Type "- [ ] Primary CTA above the fold"
Enter
Type "- [ ] Keep the copy **short**"
Sleep 600ms
Escape
Sleep 400ms
Type ":wq"
Enter
Sleep 1.5s
# Expanded card shows checkboxes in the preview
Sleep 1.5s
# --- toggle checkboxes in the ticket preview ---------------------------------
# J/K (shift) move the checklist cursor; Space toggles — j/k still move tickets
Type "J"
Sleep 700ms
Space
Sleep 1s
Type "J"
Sleep 700ms
Space
Sleep 1s
Type "K"
Sleep 700ms
Space
Sleep 1.2s
# --- status picker (letter jump) ---------------------------------------------
Type "s"
Sleep 800ms
Type "w"
Sleep 500ms
Enter
Sleep 1s
# peek another ticket, then back — checklist state stays on the edited card
Type "j"
Sleep 700ms
Type "k"
Sleep 1s
# --- board filter (multiselect) ----------------------------------------------
Type "l"
Sleep 800ms
Type "b"
Sleep 300ms
Space
Sleep 400ms
Type "i"
Sleep 300ms
Space
Sleep 400ms
Enter
Sleep 1s
# show all again
Type "l"
Sleep 600ms
Type "a"
Sleep 300ms
Enter
Sleep 800ms
# --- search / filter ---------------------------------------------------------
Type "/"
Sleep 400ms
Type "auth"
Enter
Sleep 1s
Type "/"
Sleep 300ms
Enter
Sleep 800ms
# --- projects: create + switch -----------------------------------------------
Type "p"
Sleep 800ms
Type "n"
Sleep 400ms
Type "client-acme"
Enter
Sleep 1s
Type "n"
Sleep 400ms
Type "Kickoff call notes"
Enter
Sleep 1s
Type "p"
Sleep 800ms
Type "i"
Sleep 400ms
Enter
Sleep 1s
# --- timer -------------------------------------------------------------------
Type "j"
Sleep 300ms
Type "t"
Sleep 1.5s
Type "t"
Sleep 1s
# --- manual time log ---------------------------------------------------------
Type "T"
Sleep 500ms
Type "45m"
Enter
Sleep 1s
# --- manage time entries (edit duration) -------------------------------------
Type "m"
Sleep 1s
Type "e"
Sleep 500ms
Backspace 3
Type "1h15m"
Enter
Sleep 1s
Escape
Sleep 800ms
# --- report + group-by picker ------------------------------------------------
Type "r"
Sleep 1.2s
Type "g"
Sleep 800ms
Type "t"
Sleep 400ms
Enter
Sleep 1.2s
Type "g"
Sleep 600ms
Type "d"
Sleep 400ms
Enter
Sleep 1.2s
Escape
Sleep 800ms
# --- undo / redo -------------------------------------------------------------
Type "u"
Sleep 1s
Type "U"
Sleep 1s
# --- delete with confirm -----------------------------------------------------
Type "n"
Sleep 400ms
Type "throwaway ticket"
Enter
Sleep 800ms
Type "x"
Sleep 700ms
Type "y"
Sleep 1s
# --- force delete ------------------------------------------------------------
Type "n"
Sleep 400ms
Type "another draft"
Enter
Sleep 700ms
Type "X"
Sleep 1s
# --- done --------------------------------------------------------------------
Sleep 1.5s
Type "q"
Sleep 500ms
+16 -5
View File
@@ -8,7 +8,10 @@ require (
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/google/uuid v1.6.0
github.com/mark3labs/mcp-go v0.32.0
github.com/muesli/termenv v0.16.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0
)
require (
@@ -25,23 +28,31 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/term v0.47.0 // indirect
golang.org/x/text v0.43.0 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+80 -11
View File
@@ -38,22 +38,40 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -69,27 +87,78 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+14 -11
View File
@@ -21,12 +21,13 @@ type Executor interface {
// History is an undo/redo stack for Command values, optionally persisted to disk.
type History struct {
mu sync.Mutex
undoS []Command
redoS []Command
path string
store ticket.Store
max int
mu sync.Mutex
undoS []Command
redoS []Command
path string
store ticket.Store
timeStore ticket.TimeStore
max int
}
// NewHistory creates an empty in-memory command history (cap MaxHistory).
@@ -36,13 +37,15 @@ func NewHistory() *History {
// OpenHistory loads a persisted history from path (DataDir/history.json) or
// starts empty if the file is missing. Subsequent Do/Undo/Redo write back.
func OpenHistory(path string, store ticket.Store) (*History, error) {
// timeStore may be nil if time-entry commands are unused.
func OpenHistory(path string, store ticket.Store, timeStore ticket.TimeStore) (*History, error) {
h := &History{
path: path,
store: store,
max: MaxHistory,
path: path,
store: store,
timeStore: timeStore,
max: MaxHistory,
}
undo, redo, err := loadHistoryFile(path, store)
undo, redo, err := loadHistoryFile(path, store, timeStore)
if err != nil {
return nil, err
}
+57
View File
@@ -0,0 +1,57 @@
package command_test
import (
"testing"
"time"
"task/internal/command"
"task/internal/ticket"
)
func TestMoveTicketUndo(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
timeStore := ticket.NewJSONTimeStore(dir)
h := command.NewHistory()
created, err := store.Create(ticket.Ticket{
Title: "Misfiled", ProjectID: "inbox", Status: ticket.StatusTodo,
})
if err != nil {
t.Fatal(err)
}
if _, err := timeStore.Create(ticket.TimeEntry{
TicketID: created.ID, ProjectID: "inbox", Duration: 15 * time.Minute, Source: ticket.SourceManual,
}); err != nil {
t.Fatal(err)
}
after := created
after.ProjectID = "client"
if err := h.Do(&command.MoveTicket{
Store: store, TimeStore: timeStore, Before: created, After: after,
}); err != nil {
t.Fatal(err)
}
list, _ := store.List("client")
if len(list) != 1 {
t.Fatalf("client len=%d", len(list))
}
entries, _ := timeStore.ListByTicket(created.ID)
if len(entries) != 1 || entries[0].ProjectID != "client" {
t.Fatalf("time entries: %+v", entries)
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.List("inbox")
if len(list) != 1 {
t.Fatalf("inbox after undo len=%d", len(list))
}
entries, _ = timeStore.ListByTicket(created.ID)
if len(entries) != 1 || entries[0].ProjectID != "inbox" {
t.Fatalf("time after undo: %+v", entries)
}
}
+66 -14
View File
@@ -21,17 +21,24 @@ type persistedFile struct {
}
type persistedCmd struct {
Type string `json:"type"`
Ticket *ticket.Ticket `json:"ticket,omitempty"`
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
Before *ticket.Ticket `json:"before,omitempty"`
After *ticket.Ticket `json:"after,omitempty"`
Type string `json:"type"`
Ticket *ticket.Ticket `json:"ticket,omitempty"`
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
Before *ticket.Ticket `json:"before,omitempty"`
After *ticket.Ticket `json:"after,omitempty"`
TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"`
TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"`
TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"`
}
const (
typeCreate = "create"
typeDelete = "delete"
typeUpdate = "update"
typeCreate = "create"
typeDelete = "delete"
typeUpdate = "update"
typeAddTime = "add_time"
typeRemoveTime = "remove_time"
typeUpdateTime = "update_time"
typeMove = "move"
)
// HistoryPath returns DataDir/history.json.
@@ -54,12 +61,28 @@ func encodeCmd(cmd Command) (persistedCmd, error) {
case *UpdateTicket:
b, a := c.Before, c.After
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
case *AddTimeEntry:
e := c.Entry
if c.created.ID != "" {
e = c.created
}
cp := e
return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil
case *RemoveTimeEntry:
cp := c.Snapshot
return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil
case *UpdateTimeEntry:
b, a := c.Before, c.After
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
case *MoveTicket:
b, a := c.Before, c.After
return persistedCmd{Type: typeMove, Before: &b, After: &a}, nil
default:
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
}
}
func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) {
switch rec.Type {
case typeCreate:
if rec.Ticket == nil {
@@ -76,6 +99,35 @@ func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
return nil, fmt.Errorf("update record missing before/after")
}
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
case typeAddTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("add_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("add_time: time store not configured")
}
return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil
case typeRemoveTime:
if rec.TimeEntry == nil {
return nil, fmt.Errorf("remove_time record missing entry")
}
if timeStore == nil {
return nil, fmt.Errorf("remove_time: time store not configured")
}
return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil
case typeUpdateTime:
if rec.TimeBefore == nil || rec.TimeAfter == nil {
return nil, fmt.Errorf("update_time record missing before/after")
}
if timeStore == nil {
return nil, fmt.Errorf("update_time: time store not configured")
}
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
case typeMove:
if rec.Before == nil || rec.After == nil {
return nil, fmt.Errorf("move record missing before/after")
}
return &MoveTicket{Store: store, TimeStore: timeStore, Before: *rec.Before, After: *rec.After}, nil
default:
return nil, fmt.Errorf("unknown command type %q", rec.Type)
}
@@ -93,10 +145,10 @@ func encodeStack(cmds []Command) ([]persistedCmd, error) {
return out, nil
}
func decodeStack(recs []persistedCmd, store ticket.Store) ([]Command, error) {
func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) {
out := make([]Command, 0, len(recs))
for _, rec := range recs {
cmd, err := decodeCmd(rec, store)
cmd, err := decodeCmd(rec, store, timeStore)
if err != nil {
return nil, err
}
@@ -154,7 +206,7 @@ func (h *History) persistLocked() error {
return nil
}
func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err error) {
func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@@ -169,11 +221,11 @@ func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err
if err := json.Unmarshal(data, &file); err != nil {
return nil, nil, fmt.Errorf("parse history: %w", err)
}
undo, err = decodeStack(file.Undo, store)
undo, err = decodeStack(file.Undo, store, timeStore)
if err != nil {
return nil, nil, err
}
redo, err = decodeStack(file.Redo, store)
redo, err = decodeStack(file.Redo, store, timeStore)
if err != nil {
return nil, nil, err
}
+3 -3
View File
@@ -14,7 +14,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
store := ticket.NewJSONStore(dir)
path := command.HistoryPath(dir)
h1, err := command.OpenHistory(path, store)
h1, err := command.OpenHistory(path, store, nil)
if err != nil {
t.Fatal(err)
}
@@ -30,7 +30,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
t.Fatalf("history file missing: %v", err)
}
h2, err := command.OpenHistory(path, store)
h2, err := command.OpenHistory(path, store, nil)
if err != nil {
t.Fatal(err)
}
@@ -49,7 +49,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
func TestHistoryCapsAtMax(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store)
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, nil)
if err != nil {
t.Fatal(err)
}
+42 -2
View File
@@ -9,8 +9,8 @@ import (
// CreateTicket creates a ticket; Undo deletes it.
type CreateTicket struct {
Store ticket.Store
Ticket ticket.Ticket
Store ticket.Store
Ticket ticket.Ticket
created ticket.Ticket
}
@@ -79,3 +79,43 @@ func (c *UpdateTicket) Undo(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.Before)
}
// MoveTicket relocates a ticket to another project; Undo moves it back.
type MoveTicket struct {
Store ticket.Store
TimeStore ticket.TimeStore
Before ticket.Ticket
After ticket.Ticket // ProjectID is the destination; other fields from Before
}
func (c *MoveTicket) Name() string { return "move ticket" }
func (c *MoveTicket) Execute(ctx context.Context) error {
_ = ctx
moved, err := c.Store.Move(c.Before.ID, c.After.ProjectID)
if err != nil {
return err
}
c.After = moved
if c.TimeStore != nil {
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
return err
}
}
return nil
}
func (c *MoveTicket) Undo(ctx context.Context) error {
_ = ctx
moved, err := c.Store.Move(c.After.ID, c.Before.ProjectID)
if err != nil {
return err
}
c.Before = moved
if c.TimeStore != nil {
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
return err
}
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package command
import (
"context"
"fmt"
"task/internal/ticket"
)
// AddTimeEntry creates a time entry; Undo deletes it.
type AddTimeEntry struct {
Store ticket.TimeStore
Entry ticket.TimeEntry
created ticket.TimeEntry
}
func (c *AddTimeEntry) Name() string { return "add time entry" }
func (c *AddTimeEntry) Execute(ctx context.Context) error {
_ = ctx
created, err := c.Store.Create(c.Entry)
if err != nil {
return err
}
c.created = created
c.Entry = created
return nil
}
func (c *AddTimeEntry) Undo(ctx context.Context) error {
_ = ctx
id := c.created.ID
if id == "" {
id = c.Entry.ID
}
if id == "" {
return fmt.Errorf("add time undo: missing entry id")
}
return c.Store.Delete(id)
}
// RemoveTimeEntry deletes a time entry; Undo restores the snapshot.
type RemoveTimeEntry struct {
Store ticket.TimeStore
Snapshot ticket.TimeEntry
}
func (c *RemoveTimeEntry) Name() string { return "remove time entry" }
func (c *RemoveTimeEntry) Execute(ctx context.Context) error {
_ = ctx
if c.Snapshot.ID == "" {
return fmt.Errorf("remove time: missing entry id")
}
return c.Store.Delete(c.Snapshot.ID)
}
func (c *RemoveTimeEntry) Undo(ctx context.Context) error {
_ = ctx
_, err := c.Store.Create(c.Snapshot)
return err
}
// UpdateTimeEntry applies a new entry state; Undo restores Before.
type UpdateTimeEntry struct {
Store ticket.TimeStore
Before ticket.TimeEntry
After ticket.TimeEntry
}
func (c *UpdateTimeEntry) Name() string { return "update time entry" }
func (c *UpdateTimeEntry) Execute(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.After)
}
func (c *UpdateTimeEntry) Undo(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.Before)
}
+75
View File
@@ -0,0 +1,75 @@
package command_test
import (
"testing"
"time"
"task/internal/command"
"task/internal/ticket"
)
func TestUpdateTimeEntryUndo(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONTimeStore(dir)
h := command.NewHistory()
created, err := store.Create(ticket.TimeEntry{
TicketID: "t1",
ProjectID: "inbox",
Start: time.Now().UTC().Add(-time.Hour),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
after := created
after.Duration = 30 * time.Minute
after.End = after.Start.Add(after.Duration)
if err := h.Do(&command.UpdateTimeEntry{Store: store, Before: created, After: after}); err != nil {
t.Fatal(err)
}
list, _ := store.ListByTicket("t1")
if len(list) != 1 || list[0].Duration != 30*time.Minute {
t.Fatalf("after update: %+v", list)
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.ListByTicket("t1")
if len(list) != 1 || list[0].Duration != time.Hour {
t.Fatalf("after undo: %+v", list)
}
}
func TestRemoveTimeEntryUndo(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONTimeStore(dir)
h := command.NewHistory()
created, err := store.Create(ticket.TimeEntry{
TicketID: "t1",
ProjectID: "inbox",
Start: time.Now().UTC(),
Duration: 15 * time.Minute,
Source: ticket.SourceTimer,
})
if err != nil {
t.Fatal(err)
}
if err := h.Do(&command.RemoveTimeEntry{Store: store, Snapshot: created}); err != nil {
t.Fatal(err)
}
list, _ := store.List()
if len(list) != 0 {
t.Fatalf("expected empty after remove, got %d", len(list))
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.List()
if len(list) != 1 || list[0].ID != created.ID {
t.Fatalf("after undo: %+v", list)
}
}
+28 -13
View File
@@ -3,18 +3,25 @@ package config
import "path/filepath"
const (
AppName = "task-master"
DefaultProject = "inbox"
DefaultTheme = "default"
configFileName = "config.json"
// AppName is used for config/data directory names under XDG paths.
AppName = "task-master"
// CliName is the installed binary / command name.
CliName = "tm"
DefaultProject = "inbox"
DefaultTheme = "default"
configFileName = "config.json"
envHome = "TASK_MASTER_HOME"
envDataDir = "TASK_MASTER_DATA_DIR"
)
// Config holds application settings persisted under ~/.config/task-master.
type Config struct {
ActiveProject string `json:"activeProject"`
DataDir string `json:"dataDir"`
Theme string `json:"theme"`
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done).
ActiveProject string `json:"activeProject"`
DataDir string `json:"dataDir"`
Theme string `json:"theme"`
// ProjectLists maps project ID → comma-separated board list ids
// (e.g. "all" or "backlog,in_progress").
ProjectLists map[string]string `json:"projectLists,omitempty"`
}
@@ -55,8 +62,8 @@ func (c *Config) ApplyDefaults(home string) {
}
}
// ActiveBoardList returns the persisted board list for a project (default: all).
func (c *Config) ActiveBoardList(projectID string) string {
// ActiveBoardLists returns the persisted board filter string for a project (default: "all").
func (c *Config) ActiveBoardLists(projectID string) string {
if c.ProjectLists == nil {
return "all"
}
@@ -66,10 +73,18 @@ func (c *Config) ActiveBoardList(projectID string) string {
return "all"
}
// SetActiveBoardList stores the board list for a project.
func (c *Config) SetActiveBoardList(projectID, listID string) {
// SetActiveBoardLists stores the board filter string for a project.
func (c *Config) SetActiveBoardLists(projectID, listIDs string) {
if c.ProjectLists == nil {
c.ProjectLists = map[string]string{}
}
c.ProjectLists[projectID] = listID
c.ProjectLists[projectID] = listIDs
}
// ClearProjectLists removes the persisted board filter for a project.
func (c *Config) ClearProjectLists(projectID string) {
if c.ProjectLists == nil {
return
}
delete(c.ProjectLists, projectID)
}
+24 -5
View File
@@ -5,14 +5,28 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ResolveHome returns the effective home for config/data paths.
// TASK_MASTER_HOME overrides the real user home (useful for demos/dev sandboxes).
func ResolveHome() (string, error) {
if h := strings.TrimSpace(os.Getenv(envHome)); h != "" {
return filepath.Clean(h), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return home, nil
}
// LoadOrCreate loads the config from disk, creating defaults if missing.
// It also ensures DataDir and the active project directory exist.
func LoadOrCreate() (*Config, error) {
home, err := os.UserHomeDir()
home, err := ResolveHome()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
return nil, err
}
cfgPath := Path(home)
@@ -26,6 +40,10 @@ func LoadOrCreate() (*Config, error) {
return nil, err
}
if d := strings.TrimSpace(os.Getenv(envDataDir)); d != "" {
cfg.DataDir = filepath.Clean(d)
}
if err := ensureDataDirs(cfg); err != nil {
return nil, err
}
@@ -54,11 +72,12 @@ func readOrCreate(cfgPath, home string) (*Config, error) {
return &cfg, nil
}
// Save writes the config to ~/.config/task-master/config.json.
// Save writes the config to ~/.config/task-master/config.json
// (or under TASK_MASTER_HOME when set).
func Save(cfg *Config) error {
home, err := os.UserHomeDir()
home, err := ResolveHome()
if err != nil {
return fmt.Errorf("resolve home directory: %w", err)
return err
}
cfgPath := Path(home)
+39
View File
@@ -30,6 +30,8 @@ func TestDefaultsAndApplyDefaults(t *testing.T) {
func TestSaveRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("TASK_MASTER_HOME", "")
t.Setenv("TASK_MASTER_DATA_DIR", "")
cfg := config.Defaults(home)
cfg.ActiveProject = "work"
@@ -54,6 +56,8 @@ func TestSaveRoundTrip(t *testing.T) {
func TestLoadOrCreate(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("TASK_MASTER_HOME", "")
t.Setenv("TASK_MASTER_DATA_DIR", "")
cfg, err := config.LoadOrCreate()
if err != nil {
@@ -81,3 +85,38 @@ func TestLoadOrCreate(t *testing.T) {
t.Fatalf("DataDir mismatch")
}
}
func TestTASK_MASTER_HOME(t *testing.T) {
sandbox := t.TempDir()
t.Setenv("TASK_MASTER_HOME", sandbox)
t.Setenv("TASK_MASTER_DATA_DIR", "")
cfg, err := config.LoadOrCreate()
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
wantCfg := config.Path(sandbox)
if _, err := os.Stat(wantCfg); err != nil {
t.Fatalf("config under sandbox missing: %v", err)
}
wantData := filepath.Join(sandbox, ".local", "share", config.AppName)
if cfg.DataDir != wantData {
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, wantData)
}
}
func TestTASK_MASTER_DATA_DIR(t *testing.T) {
home := t.TempDir()
data := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("TASK_MASTER_HOME", "")
t.Setenv("TASK_MASTER_DATA_DIR", data)
cfg, err := config.LoadOrCreate()
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if cfg.DataDir != data {
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, data)
}
}
+115
View File
@@ -0,0 +1,115 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/command"
"task/internal/daemon/api"
"task/internal/project"
"task/internal/ticket"
)
func setup(t *testing.T) (*api.Server, string) {
t.Helper()
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
timeStore := ticket.NewJSONTimeStore(dir)
projects := project.NewDirStore(dir)
_ = projects.Ensure("inbox")
hist, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, timeStore)
if err != nil {
t.Fatal(err)
}
return api.New(store, timeStore, projects, hist), dir
}
func TestHealthAndCRUD(t *testing.T) {
srv, _ := setup(t)
h := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("health: %d", rec.Code)
}
body, _ := json.Marshal(ticket.Ticket{Title: "Hi", ProjectID: "inbox", Status: ticket.StatusTodo})
req = httptest.NewRequest(http.MethodPost, "/v1/tickets", bytes.NewReader(body))
req.Header.Set(api.HeaderClient, api.ClientTUI)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created ticket.Ticket
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("missing id")
}
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientTUI)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("list: %d", rec.Code)
}
}
func TestMCPAuthRequired(t *testing.T) {
srv, _ := setup(t)
h := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientMCP)
req.Header.Set("X-TM-Project", "inbox")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
t.Fatalf("expected auth failure, got %d %s", rec.Code, rec.Body.String())
}
// Create token via TUI client
req = httptest.NewRequest(http.MethodPost, "/v1/projects/inbox/mcp-token", bytes.NewReader([]byte("{}")))
req.Header.Set(api.HeaderClient, api.ClientTUI)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("token create: %d %s", rec.Code, rec.Body.String())
}
var tok struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
if tok.Token == "" {
t.Fatal("empty token")
}
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientMCP)
req.Header.Set("Authorization", "Bearer "+tok.Token)
req.Header.Set("X-TM-Project", "inbox")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("mcp list: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
req.Header.Set(api.HeaderClient, api.ClientMCP)
req.Header.Set("Authorization", "Bearer wrong")
req.Header.Set("X-TM-Project", "inbox")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 200 {
t.Fatal("wrong token should fail")
}
}
+117
View File
@@ -0,0 +1,117 @@
package api
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
)
type ctxKey int
const (
ctxClientKind ctxKey = iota
ctxMCPProject
)
const (
HeaderClient = "X-TM-Client"
ClientTUI = "tui"
ClientCLI = "cli"
ClientMCP = "mcp"
)
// ClientKind returns the authenticated client kind from context.
func ClientKind(ctx context.Context) string {
v, _ := ctx.Value(ctxClientKind).(string)
return v
}
// MCPProjectID returns the project bound to a validated MCP token, if any.
func MCPProjectID(ctx context.Context) string {
v, _ := ctx.Value(ctxMCPProject).(string)
return v
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/health" {
next.ServeHTTP(w, r)
return
}
kind := strings.ToLower(strings.TrimSpace(r.Header.Get(HeaderClient)))
if kind == "" {
kind = ClientTUI
}
ctx := context.WithValue(r.Context(), ctxClientKind, kind)
if kind == ClientMCP {
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) {
writeErr(w, http.StatusUnauthorized, "mcp requires Authorization: Bearer <token>")
return
}
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
if token == "" {
writeErr(w, http.StatusUnauthorized, "empty bearer token")
return
}
projectID := strings.TrimSpace(r.Header.Get("X-TM-Project"))
if projectID == "" {
projectID = extractProjectID(r)
}
if projectID == "" {
writeErr(w, http.StatusUnauthorized, "mcp requires project id (X-TM-Project or path/body)")
return
}
meta, err := s.projects.LoadMeta(projectID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if meta.McpAuth == nil || meta.McpAuth.TokenHash == "" {
writeErr(w, http.StatusForbidden, "no mcp token configured for project")
return
}
got := hashToken(token)
if subtle.ConstantTimeCompare([]byte(got), []byte(meta.McpAuth.TokenHash)) != 1 {
writeErr(w, http.StatusForbidden, "invalid mcp token")
return
}
ctx = context.WithValue(ctx, ctxMCPProject, projectID)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// extractProjectID best-effort for auth before body is consumed.
// Path patterns: /v1/projects/{id}/...
func extractProjectID(r *http.Request) string {
if q := strings.TrimSpace(r.URL.Query().Get("projectId")); q != "" {
return q
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
// v1 projects {id} ...
if len(parts) >= 3 && parts[0] == "v1" && parts[1] == "projects" {
return parts[2]
}
return ""
}
// requireMCPProject ensures MCP calls stay on the token's project.
func requireMCPProject(r *http.Request, projectID string) bool {
if ClientKind(r.Context()) != ClientMCP {
return true
}
bound := MCPProjectID(r.Context())
return bound != "" && bound == projectID
}
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net/http"
)
func (s *Server) handleHistoryStatus(w http.ResponseWriter, r *http.Request) {
_ = r
writeJSON(w, http.StatusOK, map[string]any{
"canUndo": s.history.CanUndo(),
"canRedo": s.history.CanRedo(),
"lastUndoName": s.history.LastUndoName(),
"lastRedoName": s.history.LastRedoName(),
})
}
func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
_ = r
name := s.history.LastUndoName()
if err := s.history.Undo(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify("")
writeJSON(w, http.StatusOK, map[string]string{"name": name})
}
func (s *Server) handleRedo(w http.ResponseWriter, r *http.Request) {
_ = r
name := s.history.LastRedoName()
if err := s.history.Redo(); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify("")
writeJSON(w, http.StatusOK, map[string]string{"name": name})
}
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"sync"
)
// Event is a change notification pushed to SSE clients.
type Event struct {
Type string `json:"type"`
ProjectID string `json:"projectId,omitempty"`
}
type hub struct {
mu sync.Mutex
clients map[chan Event]struct{}
}
func newHub() *hub {
return &hub{clients: map[chan Event]struct{}{}}
}
func (h *hub) subscribe() chan Event {
ch := make(chan Event, 16)
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *hub) unsubscribe(ch chan Event) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *hub) publish(ev Event) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
select {
case ch <- ev:
default:
}
}
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := s.hub.subscribe()
defer s.hub.unsubscribe(ch)
fmt.Fprintf(w, ": ok\n\n")
flusher.Flush()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case ev, ok := <-ch:
if !ok {
return
}
data, _ := json.Marshal(ev)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package api
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(r *http.Request, dst any) error {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
+183
View File
@@ -0,0 +1,183 @@
package api
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"task/internal/project"
)
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
list, err := s.projects.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if ClientKind(r.Context()) == ClientMCP {
pid := MCPProjectID(r.Context())
filtered := make([]project.Project, 0, 1)
for _, p := range list {
if p.ID == pid {
filtered = append(filtered, p)
}
}
list = filtered
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleEnsureProject(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string `json:"id"`
}
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if !requireMCPProject(r, body.ID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
if err := s.projects.Ensure(body.ID); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(body.ID)
writeJSON(w, http.StatusOK, map[string]string{"id": body.ID})
}
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !requireMCPProject(r, id) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
if err := s.timeStore.DeleteByProject(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if t, err := s.timeStore.LoadActiveTimer(); err == nil && t != nil && t.ProjectID == id {
_ = s.timeStore.SaveActiveTimer(nil)
}
if err := s.projects.Delete(id); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(id)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !requireMCPProject(r, id) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, meta)
}
func (s *Server) handleSaveMeta(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !requireMCPProject(r, id) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var meta project.Meta
if err := readJSON(r, &meta); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
// Preserve existing mcpAuth if client omitted it (TUI sync edit).
prev, _ := s.projects.LoadMeta(id)
if meta.McpAuth == nil && prev.McpAuth != nil {
meta.McpAuth = prev.McpAuth
}
if err := s.projects.SaveMeta(id, meta); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
s.notify(id)
writeJSON(w, http.StatusOK, meta)
}
func (s *Server) handleMCPTokenStatus(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if ClientKind(r.Context()) == ClientMCP {
writeErr(w, http.StatusForbidden, "mcp cannot inspect token status")
return
}
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
configured := meta.McpAuth != nil && meta.McpAuth.TokenHash != ""
created := ""
if meta.McpAuth != nil {
created = meta.McpAuth.CreatedAt
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": configured,
"createdAt": created,
})
}
func (s *Server) handleMCPTokenCreate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if ClientKind(r.Context()) == ClientMCP {
writeErr(w, http.StatusForbidden, "mcp cannot create tokens")
return
}
if err := s.projects.Ensure(id); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
raw := make([]byte, 24)
if _, err := rand.Read(raw); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
token := "tm_mcp_" + hex.EncodeToString(raw)
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
created := time.Now().UTC().Format(time.RFC3339)
meta.McpAuth = &project.McpAuth{TokenHash: hashToken(token), CreatedAt: created}
if err := s.projects.SaveMeta(id, meta); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": token,
"project": id,
"createdAt": created,
})
}
func (s *Server) handleMCPTokenRevoke(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if ClientKind(r.Context()) == ClientMCP {
writeErr(w, http.StatusForbidden, "mcp cannot revoke tokens")
return
}
meta, err := s.projects.LoadMeta(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
meta.McpAuth = nil
if err := s.projects.SaveMeta(id, meta); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net/http"
"task/internal/command"
"task/internal/project"
"task/internal/ticket"
)
// Server is the daemon HTTP API.
type Server struct {
store ticket.Store
timeStore ticket.TimeStore
projects project.Store
history command.Executor
hub *hub
mux *http.ServeMux
}
// New constructs the API server.
func New(store ticket.Store, timeStore ticket.TimeStore, projects project.Store, history command.Executor) *Server {
s := &Server{
store: store,
timeStore: timeStore,
projects: projects,
history: history,
hub: newHub(),
mux: http.NewServeMux(),
}
s.routes()
return s
}
// Handler returns the root HTTP handler with auth middleware.
func (s *Server) Handler() http.Handler {
return s.withAuth(s.mux)
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/projects", s.handleListProjects)
s.mux.HandleFunc("POST /v1/projects", s.handleEnsureProject)
s.mux.HandleFunc("DELETE /v1/projects/{id}", s.handleDeleteProject)
s.mux.HandleFunc("GET /v1/projects/{id}/meta", s.handleGetMeta)
s.mux.HandleFunc("PUT /v1/projects/{id}/meta", s.handleSaveMeta)
s.mux.HandleFunc("GET /v1/projects/{id}/mcp-token", s.handleMCPTokenStatus)
s.mux.HandleFunc("POST /v1/projects/{id}/mcp-token", s.handleMCPTokenCreate)
s.mux.HandleFunc("DELETE /v1/projects/{id}/mcp-token", s.handleMCPTokenRevoke)
s.mux.HandleFunc("GET /v1/tickets", s.handleListTickets)
s.mux.HandleFunc("GET /v1/tickets/{id}", s.handleGetTicket)
s.mux.HandleFunc("POST /v1/tickets", s.handleCreateTicket)
s.mux.HandleFunc("PATCH /v1/tickets/{id}", s.handleUpdateTicket)
s.mux.HandleFunc("DELETE /v1/tickets/{id}", s.handleDeleteTicket)
s.mux.HandleFunc("POST /v1/tickets/{id}/move", s.handleMoveTicket)
s.mux.HandleFunc("GET /v1/time", s.handleListTime)
s.mux.HandleFunc("GET /v1/time/by-ticket/{ticketId}", s.handleListTimeByTicket)
s.mux.HandleFunc("GET /v1/time/sum/{ticketId}", s.handleSumTime)
s.mux.HandleFunc("POST /v1/time", s.handleAddTime)
s.mux.HandleFunc("PATCH /v1/time/{id}", s.handleUpdateTime)
s.mux.HandleFunc("DELETE /v1/time/{id}", s.handleRemoveTime)
s.mux.HandleFunc("GET /v1/timer", s.handleGetTimer)
s.mux.HandleFunc("PUT /v1/timer", s.handleSetTimer)
s.mux.HandleFunc("GET /v1/history", s.handleHistoryStatus)
s.mux.HandleFunc("POST /v1/undo", s.handleUndo)
s.mux.HandleFunc("POST /v1/redo", s.handleRedo)
s.mux.HandleFunc("POST /v1/sync/pull", s.handleSyncPull)
s.mux.HandleFunc("POST /v1/sync/push", s.handleSyncPush)
s.mux.HandleFunc("GET /v1/events", s.handleEvents)
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) notify(projectID string) {
s.hub.publish(Event{Type: "changed", ProjectID: projectID})
}
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"context"
"net/http"
"task/internal/project"
"task/internal/tmsync"
"task/internal/ytsync"
)
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, false)
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, true)
}
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, push bool) {
var body struct {
ProjectID string `json:"projectId"`
}
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if body.ProjectID == "" {
writeErr(w, http.StatusBadRequest, "projectId required")
return
}
if !requireMCPProject(r, body.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
meta, err := s.projects.LoadMeta(body.ProjectID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var res ytsync.Result
ctx := context.Background()
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
writeErr(w, http.StatusBadRequest, "taskmaster not configured")
return
}
syncer := &tmsync.Syncer{Store: s.store, TimeStore: s.timeStore}
if push {
res, err = syncer.Push(ctx, body.ProjectID, *meta.TaskMaster)
} else {
res, err = syncer.Pull(ctx, body.ProjectID, *meta.TaskMaster)
}
default:
if push {
res, err = ytsync.PushProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
} else {
res, err = ytsync.PullProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
}
}
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(body.ProjectID)
writeJSON(w, http.StatusOK, res)
}
+147
View File
@@ -0,0 +1,147 @@
package api
import (
"net/http"
"task/internal/command"
"task/internal/ticket"
)
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
projectID := r.URL.Query().Get("projectId")
if projectID == "" {
writeErr(w, http.StatusBadRequest, "projectId query required")
return
}
if !requireMCPProject(r, projectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
list, err := s.store.List(projectID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
var body ticket.Ticket
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if !requireMCPProject(r, body.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.CreateTicket{Store: s.store, Ticket: body}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(cmd.Ticket.ProjectID)
writeJSON(w, http.StatusCreated, cmd.Ticket)
}
func (s *Server) handleUpdateTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
before, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var after ticket.Ticket
if err := readJSON(r, &after); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
after.ID = id
if after.ProjectID == "" {
after.ProjectID = before.ProjectID
}
if !requireMCPProject(r, after.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.UpdateTicket{Store: s.store, Before: before, After: after}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(after.ProjectID)
writeJSON(w, http.StatusOK, after)
}
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
before, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.DeleteTicket{Store: s.store, Snapshot: before}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(before.ProjectID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
before, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var body struct {
ToProjectID string `json:"toProjectId"`
}
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
// MCP may only move within its project (no cross-project).
if ClientKind(r.Context()) == ClientMCP && body.ToProjectID != MCPProjectID(r.Context()) {
writeErr(w, http.StatusForbidden, "mcp cannot move tickets to another project")
return
}
after := before
after.ProjectID = body.ToProjectID
cmd := &command.MoveTicket{Store: s.store, TimeStore: s.timeStore, Before: before, After: after}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(before.ProjectID)
s.notify(body.ToProjectID)
writeJSON(w, http.StatusOK, cmd.After)
}
+214
View File
@@ -0,0 +1,214 @@
package api
import (
"encoding/json"
"net/http"
"time"
"task/internal/command"
"task/internal/ticket"
)
func (s *Server) handleListTime(w http.ResponseWriter, r *http.Request) {
list, err := s.timeStore.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if ClientKind(r.Context()) == ClientMCP {
pid := MCPProjectID(r.Context())
filtered := make([]ticket.TimeEntry, 0)
for _, e := range list {
if e.ProjectID == pid {
filtered = append(filtered, e)
}
}
list = filtered
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleListTimeByTicket(w http.ResponseWriter, r *http.Request) {
ticketID := r.PathValue("ticketId")
t, err := s.store.Get(ticketID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
list, err := s.timeStore.ListByTicket(ticketID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleSumTime(w http.ResponseWriter, r *http.Request) {
ticketID := r.PathValue("ticketId")
t, err := s.store.Get(ticketID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
d, err := s.timeStore.SumByTicket(ticketID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"duration": int64(d)})
}
func (s *Server) handleAddTime(w http.ResponseWriter, r *http.Request) {
var body ticket.TimeEntry
if err := readJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if !requireMCPProject(r, body.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.AddTimeEntry{Store: s.timeStore, Entry: body}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(cmd.Entry.ProjectID)
writeJSON(w, http.StatusCreated, cmd.Entry)
}
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
entries, err := s.timeStore.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var before *ticket.TimeEntry
for i := range entries {
if entries[i].ID == id {
before = &entries[i]
break
}
}
if before == nil {
writeErr(w, http.StatusNotFound, "time entry not found")
return
}
if !requireMCPProject(r, before.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
var after ticket.TimeEntry
if err := readJSON(r, &after); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
after.ID = id
if after.ProjectID == "" {
after.ProjectID = before.ProjectID
}
cmd := &command.UpdateTimeEntry{Store: s.timeStore, Before: *before, After: after}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(after.ProjectID)
writeJSON(w, http.StatusOK, after)
}
func (s *Server) handleRemoveTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
entries, err := s.timeStore.List()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var snap *ticket.TimeEntry
for i := range entries {
if entries[i].ID == id {
snap = &entries[i]
break
}
}
if snap == nil {
writeErr(w, http.StatusNotFound, "time entry not found")
return
}
if !requireMCPProject(r, snap.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
cmd := &command.RemoveTimeEntry{Store: s.timeStore, Snapshot: *snap}
if err := s.history.Do(cmd); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
s.notify(snap.ProjectID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
t, err := s.timeStore.LoadActiveTimer()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if t != nil && ClientKind(r.Context()) == ClientMCP && t.ProjectID != MCPProjectID(r.Context()) {
writeJSON(w, http.StatusOK, nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleSetTimer(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var raw map[string]any
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
var body *ticket.ActiveTimer
if raw != nil {
b, err := json.Marshal(raw)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
var t ticket.ActiveTimer
if err := json.Unmarshal(b, &t); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if t.TicketID == "" && t.ProjectID == "" {
body = nil
} else {
if !requireMCPProject(r, t.ProjectID) {
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
return
}
if t.StartedAt.IsZero() {
t.StartedAt = time.Now()
}
body = &t
}
}
if err := s.timeStore.SaveActiveTimer(body); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if body != nil {
s.notify(body.ProjectID)
} else {
s.notify("")
}
writeJSON(w, http.StatusOK, body)
}
+111
View File
@@ -0,0 +1,111 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"task/internal/daemon/api"
)
// Client talks to the local JSON daemon over a Unix socket.
type Client struct {
http *http.Client
kind string // tui | cli | mcp
token string // bearer for mcp
project string // mcp bound project
}
// Options configures a daemon client.
type Options struct {
Socket string
Kind string
Token string
Project string
}
// Dial creates a client for the given Unix socket.
func Dial(opts Options) *Client {
kind := opts.Kind
if kind == "" {
kind = api.ClientTUI
}
return &Client{
kind: kind,
token: opts.Token,
project: opts.Project,
http: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", opts.Socket)
},
},
},
}
}
func (c *Client) do(method, path string, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, "http://daemon"+path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set(api.HeaderClient, c.kind)
if c.kind == api.ClientMCP {
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if c.project != "" {
req.Header.Set("X-TM-Project", c.project)
}
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
var er struct {
Error string `json:"error"`
}
_ = json.Unmarshal(data, &er)
if er.Error == "" {
er.Error = strings.TrimSpace(string(data))
}
if er.Error == "" {
er.Error = resp.Status
}
return fmt.Errorf("%s", er.Error)
}
if out == nil || resp.StatusCode == http.StatusNoContent || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}
// Health checks /v1/health.
func (c *Client) Health() error {
return c.do(http.MethodGet, "/v1/health", nil, nil)
}
+53
View File
@@ -0,0 +1,53 @@
package client
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"task/internal/daemon/api"
)
// SubscribeEvents streams SSE change events until ctx is cancelled.
func (c *Client) SubscribeEvents(ctx context.Context, emit func(api.Event)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://daemon/v1/events", nil)
if err != nil {
return err
}
req.Header.Set(api.HeaderClient, c.kind)
if c.kind == api.ClientMCP {
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if c.project != "" {
req.Header.Set("X-TM-Project", c.project)
}
}
// No overall timeout for long-lived SSE.
httpClient := *c.http
httpClient.Timeout = 0
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("events: %s", resp.Status)
}
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
var ev api.Event
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil {
continue
}
emit(ev)
}
return sc.Err()
}
+100
View File
@@ -0,0 +1,100 @@
package client
import (
"fmt"
"net/http"
"net/url"
"task/internal/command"
"task/internal/ticket"
)
// Executor implements command.Executor via daemon mutation endpoints.
type Executor struct{ c *Client }
func (c *Client) Executor() *Executor { return &Executor{c: c} }
func (e *Executor) Do(cmd command.Command) error {
switch c := cmd.(type) {
case *command.CreateTicket:
var out ticket.Ticket
if err := e.c.do(http.MethodPost, "/v1/tickets", c.Ticket, &out); err != nil {
return err
}
c.Ticket = out
return nil
case *command.UpdateTicket:
return e.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(c.After.ID), c.After, nil)
case *command.DeleteTicket:
return e.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(c.Snapshot.ID), nil, nil)
case *command.MoveTicket:
var out ticket.Ticket
if err := e.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(c.Before.ID)+"/move", map[string]string{
"toProjectId": c.After.ProjectID,
}, &out); err != nil {
return err
}
c.After = out
return nil
case *command.AddTimeEntry:
var out ticket.TimeEntry
if err := e.c.do(http.MethodPost, "/v1/time", c.Entry, &out); err != nil {
return err
}
c.Entry = out
return nil
case *command.RemoveTimeEntry:
return e.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(c.Snapshot.ID), nil, nil)
case *command.UpdateTimeEntry:
return e.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(c.After.ID), c.After, nil)
default:
return fmt.Errorf("unsupported command type %T", cmd)
}
}
func (e *Executor) Undo() error {
return e.c.do(http.MethodPost, "/v1/undo", map[string]any{}, nil)
}
func (e *Executor) Redo() error {
return e.c.do(http.MethodPost, "/v1/redo", map[string]any{}, nil)
}
func (e *Executor) CanUndo() bool {
st, err := e.status()
if err != nil {
return false
}
return st.CanUndo
}
func (e *Executor) CanRedo() bool {
st, err := e.status()
if err != nil {
return false
}
return st.CanRedo
}
func (e *Executor) LastUndoName() string {
st, _ := e.status()
return st.LastUndoName
}
func (e *Executor) LastRedoName() string {
st, _ := e.status()
return st.LastRedoName
}
type historyStatus struct {
CanUndo bool `json:"canUndo"`
CanRedo bool `json:"canRedo"`
LastUndoName string `json:"lastUndoName"`
LastRedoName string `json:"lastRedoName"`
}
func (e *Executor) status() (historyStatus, error) {
var st historyStatus
err := e.c.do(http.MethodGet, "/v1/history", nil, &st)
return st, err
}
+159
View File
@@ -0,0 +1,159 @@
package client
import (
"fmt"
"net/http"
"net/url"
"time"
"task/internal/project"
"task/internal/ticket"
)
// TicketStore implements ticket.Store against the daemon (reads + MarkSynced).
// Mutations used by the TUI go through Executor; Create/Update/Delete/Move here
// call the daemon mutation endpoints (with undo) for completeness.
type TicketStore struct{ c *Client }
func (c *Client) TicketStore() *TicketStore { return &TicketStore{c: c} }
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
var out []ticket.Ticket
q := url.Values{"projectId": {projectID}}.Encode()
err := s.c.do(http.MethodGet, "/v1/tickets?"+q, nil, &out)
if out == nil {
out = []ticket.Ticket{}
}
return out, err
}
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
var out ticket.Ticket
err := s.c.do(http.MethodGet, "/v1/tickets/"+url.PathEscape(id), nil, &out)
return out, err
}
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
var out ticket.Ticket
err := s.c.do(http.MethodPost, "/v1/tickets", t, &out)
return out, err
}
func (s *TicketStore) Update(t ticket.Ticket) error {
return s.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(t.ID), t, nil)
}
func (s *TicketStore) MarkSynced(id string) error {
return fmt.Errorf("MarkSynced is daemon-internal (use sync endpoints)")
}
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
var out ticket.Ticket
err := s.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(id)+"/move", map[string]string{
"toProjectId": toProjectID,
}, &out)
return out, err
}
func (s *TicketStore) Delete(id string) error {
return s.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(id), nil, nil)
}
// TimeStore implements ticket.TimeStore.
type TimeStore struct{ c *Client }
func (c *Client) TimeStore() *TimeStore { return &TimeStore{c: c} }
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
err := s.c.do(http.MethodGet, "/v1/time", nil, &out)
if out == nil {
out = []ticket.TimeEntry{}
}
return out, err
}
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
err := s.c.do(http.MethodGet, "/v1/time/by-ticket/"+url.PathEscape(ticketID), nil, &out)
if out == nil {
out = []ticket.TimeEntry{}
}
return out, err
}
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
var out ticket.TimeEntry
err := s.c.do(http.MethodPost, "/v1/time", e, &out)
return out, err
}
func (s *TimeStore) Update(e ticket.TimeEntry) error {
return s.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(e.ID), e, nil)
}
func (s *TimeStore) Delete(id string) error {
return s.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(id), nil, nil)
}
func (s *TimeStore) DeleteByProject(projectID string) error {
// Composite delete is done server-side on project delete; not exposed alone.
return fmt.Errorf("DeleteByProject is daemon-internal")
}
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
return fmt.Errorf("RetargetTicket is daemon-internal")
}
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
var out struct {
Duration int64 `json:"duration"`
}
err := s.c.do(http.MethodGet, "/v1/time/sum/"+url.PathEscape(ticketID), nil, &out)
return time.Duration(out.Duration), err
}
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
var out *ticket.ActiveTimer
err := s.c.do(http.MethodGet, "/v1/timer", nil, &out)
return out, err
}
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
if t == nil {
return s.c.do(http.MethodPut, "/v1/timer", map[string]any{}, nil)
}
return s.c.do(http.MethodPut, "/v1/timer", t, nil)
}
// ProjectStore implements project.Store.
type ProjectStore struct{ c *Client }
func (c *Client) ProjectStore() *ProjectStore { return &ProjectStore{c: c} }
func (s *ProjectStore) List() ([]project.Project, error) {
var out []project.Project
err := s.c.do(http.MethodGet, "/v1/projects", nil, &out)
if out == nil {
out = []project.Project{}
}
return out, err
}
func (s *ProjectStore) Ensure(id string) error {
return s.c.do(http.MethodPost, "/v1/projects", map[string]string{"id": id}, nil)
}
func (s *ProjectStore) Delete(id string) error {
return s.c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(id), nil, nil)
}
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
var out project.Meta
err := s.c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(id)+"/meta", nil, &out)
return out, err
}
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
return s.c.do(http.MethodPut, "/v1/projects/"+url.PathEscape(id)+"/meta", meta, nil)
}
+47
View File
@@ -0,0 +1,47 @@
package client
import (
"net/http"
"net/url"
"task/internal/ytsync"
)
// SyncPull runs daemon sync pull for projectID.
func (c *Client) SyncPull(projectID string) (ytsync.Result, error) {
var out ytsync.Result
err := c.do(http.MethodPost, "/v1/sync/pull", map[string]string{"projectId": projectID}, &out)
return out, err
}
// SyncPush runs daemon sync push for projectID.
func (c *Client) SyncPush(projectID string) (ytsync.Result, error) {
var out ytsync.Result
err := c.do(http.MethodPost, "/v1/sync/push", map[string]string{"projectId": projectID}, &out)
return out, err
}
// CreateMCPToken creates a new MCP token for projectID (plaintext once).
func (c *Client) CreateMCPToken(projectID string) (token, createdAt string, err error) {
var out struct {
Token string `json:"token"`
CreatedAt string `json:"createdAt"`
}
err = c.do(http.MethodPost, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", map[string]any{}, &out)
return out.Token, out.CreatedAt, err
}
// RevokeMCPToken removes the MCP token for projectID.
func (c *Client) RevokeMCPToken(projectID string) error {
return c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, nil)
}
// MCPTokenStatus returns whether a token is configured.
func (c *Client) MCPTokenStatus(projectID string) (configured bool, createdAt string, err error) {
var out struct {
Configured bool `json:"configured"`
CreatedAt string `json:"createdAt"`
}
err = c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, &out)
return out.Configured, out.CreatedAt, err
}
+159
View File
@@ -0,0 +1,159 @@
package daemon
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"task/internal/config"
)
// Status describes whether a daemon is reachable for cfg.DataDir.
type Status struct {
Running bool
PID int
Socket string
DataDir string
}
// StatusOf returns daemon status for the given config.
func StatusOf(cfg *config.Config) (Status, error) {
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return Status{}, err
}
st := Status{Socket: p.Socket, DataDir: cfg.DataDir}
pid, err := readPID(p.PIDFile)
if err == nil && processAlive(pid) {
st.PID = pid
}
if Healthy(p.Socket) {
st.Running = true
return st, nil
}
return st, nil
}
// Healthy reports whether the daemon HTTP health endpoint responds on socket.
func Healthy(socket string) bool {
cli := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socket)
},
},
Timeout: 500 * time.Millisecond,
}
resp, err := cli.Get("http://daemon/v1/health")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// Ensure starts the daemon if needed and waits until it is healthy.
func Ensure(cfg *config.Config) (Paths, error) {
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return Paths{}, err
}
if Healthy(p.Socket) {
return p, nil
}
cleanupStale(p)
if err := Spawn(cfg, p); err != nil {
return Paths{}, err
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if Healthy(p.Socket) {
return p, nil
}
time.Sleep(50 * time.Millisecond)
}
return Paths{}, fmt.Errorf("daemon did not become healthy at %s", p.Socket)
}
// Stop terminates the daemon for cfg.DataDir if running.
func Stop(cfg *config.Config) error {
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return err
}
pid, err := readPID(p.PIDFile)
if err != nil {
if os.IsNotExist(err) {
cleanupStale(p)
return nil
}
return err
}
if processAlive(pid) {
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return fmt.Errorf("signal daemon: %w", err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if !processAlive(pid) {
break
}
time.Sleep(50 * time.Millisecond)
}
if processAlive(pid) {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
cleanupStale(p)
return nil
}
// Spawn starts a detached daemon process for cfg.
func Spawn(cfg *config.Config, p Paths) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("executable: %w", err)
}
logFile, err := os.OpenFile(p.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
defer logFile.Close()
cmd := exec.Command(exe, "daemon")
cmd.Env = os.Environ()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("spawn daemon: %w", err)
}
// Detach: do not wait; child writes its own PID file when listening.
_ = cmd.Process.Release()
return nil
}
func cleanupStale(p Paths) {
_ = os.Remove(p.Socket)
_ = os.Remove(p.PIDFile)
}
// AbsoluteDataDir ensures DataDir is absolute for path hashing.
func AbsoluteDataDir(dataDir string) (string, error) {
if filepath.IsAbs(dataDir) {
return filepath.Clean(dataDir), nil
}
abs, err := filepath.Abs(dataDir)
if err != nil {
return "", err
}
return abs, nil
}
+91
View File
@@ -0,0 +1,91 @@
package daemon
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
const (
sockName = "daemon.sock"
pidName = "daemon.pid"
logName = "daemon.log"
)
// Paths holds filesystem locations for one DataDir's daemon instance.
type Paths struct {
RuntimeDir string
Socket string
PIDFile string
LogFile string
}
// PathsForDataDir returns runtime paths unique to dataDir.
func PathsForDataDir(dataDir string) (Paths, error) {
dataDir = filepath.Clean(dataDir)
sum := sha256.Sum256([]byte(dataDir))
id := hex.EncodeToString(sum[:8])
dir, err := mkdirRuntime(id, dataDir)
if err != nil {
return Paths{}, err
}
return Paths{
RuntimeDir: dir,
Socket: filepath.Join(dir, sockName),
PIDFile: filepath.Join(dir, pidName),
LogFile: filepath.Join(dir, logName),
}, nil
}
func mkdirRuntime(id, dataDir string) (string, error) {
candidates := []string{}
if r := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); r != "" {
candidates = append(candidates, filepath.Join(r, "task-master-"+id))
}
candidates = append(candidates,
filepath.Join(os.TempDir(), fmt.Sprintf("task-master-%d-%s", os.Getuid(), id)),
filepath.Join(dataDir, ".runtime"),
)
var last error
for _, dir := range candidates {
if err := os.MkdirAll(dir, 0o700); err != nil {
last = err
continue
}
return dir, nil
}
if last == nil {
last = fmt.Errorf("no runtime dir candidate")
}
return "", fmt.Errorf("create runtime dir: %w", last)
}
func writePID(path string, pid int) error {
return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600)
}
func readPID(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(data))
if s == "" {
return 0, fmt.Errorf("empty pid file")
}
return strconv.Atoi(s)
}
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil
}
+79
View File
@@ -0,0 +1,79 @@
package daemon
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"task/internal/command"
"task/internal/config"
"task/internal/daemon/api"
"task/internal/project"
"task/internal/ticket"
)
// Run starts the foreground daemon for cfg until signal or context cancel.
func Run(cfg *config.Config) error {
abs, err := AbsoluteDataDir(cfg.DataDir)
if err != nil {
return err
}
cfg.DataDir = abs
p, err := PathsForDataDir(cfg.DataDir)
if err != nil {
return err
}
cleanupStale(p)
store := ticket.NewJSONStore(cfg.DataDir)
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
if err != nil {
return fmt.Errorf("history: %w", err)
}
srvAPI := api.New(store, timeStore, projects, history)
server := &http.Server{Handler: srvAPI.Handler()}
ln, err := net.Listen("unix", p.Socket)
if err != nil {
return fmt.Errorf("listen %s: %w", p.Socket, err)
}
if err := os.Chmod(p.Socket, 0o600); err != nil {
_ = ln.Close()
return fmt.Errorf("chmod socket: %w", err)
}
if err := writePID(p.PIDFile, os.Getpid()); err != nil {
_ = ln.Close()
return fmt.Errorf("pid file: %w", err)
}
defer cleanupStale(p)
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(ln)
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case <-sigCh:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
return nil
case err := <-errCh:
if err == http.ErrServerClosed {
return nil
}
return err
}
}
+278
View File
@@ -0,0 +1,278 @@
package mcp
import (
"context"
"encoding/json"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"task/internal/daemon/client"
"task/internal/ticket"
)
// ServeStdio runs the MCP server on stdin/stdout using auth from .tm_token.
func ServeStdio(cli *client.Client, tf TokenFile) error {
s := server.NewMCPServer(
"task-master",
"1.0.0",
server.WithToolCapabilities(true),
)
registerTools(s, cli, tf)
return server.ServeStdio(s)
}
func registerTools(s *server.MCPServer, cli *client.Client, tf TokenFile) {
jsonResult := func(v any) (*mcp.CallToolResult, error) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(string(b)), nil
}
fail := func(err error) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError(err.Error()), nil
}
s.AddTool(mcp.NewTool("list_projects",
mcp.WithDescription("List the TM project bound to .tm_token"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
list, err := cli.ProjectStore().List()
if err != nil {
return fail(err)
}
return jsonResult(list)
})
s.AddTool(mcp.NewTool("list_tickets",
mcp.WithDescription("List tickets in the token project"),
mcp.WithString("status", mcp.Description("Optional status filter: todo, in_progress, done")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
list, err := cli.TicketStore().List(tf.Project)
if err != nil {
return fail(err)
}
status := req.GetString("status", "")
if status != "" {
filtered := list[:0]
for _, t := range list {
if string(t.Status) == status {
filtered = append(filtered, t)
}
}
list = filtered
}
return jsonResult(list)
})
s.AddTool(mcp.NewTool("get_ticket",
mcp.WithDescription("Get one ticket by id"),
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
t, err := cli.TicketStore().Get(id)
if err != nil {
return fail(err)
}
return jsonResult(t)
})
s.AddTool(mcp.NewTool("create_ticket",
mcp.WithDescription("Create a ticket in the token project"),
mcp.WithString("title", mcp.Required(), mcp.Description("Ticket title")),
mcp.WithString("description", mcp.Description("Markdown body")),
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
title, err := req.RequireString("title")
if err != nil {
return fail(err)
}
st := ticket.Status(req.GetString("status", string(ticket.StatusTodo)))
t := ticket.Ticket{
Title: title,
Description: req.GetString("description", ""),
Status: st,
ProjectID: tf.Project,
}
created, err := cli.TicketStore().Create(t)
if err != nil {
return fail(err)
}
return jsonResult(created)
})
s.AddTool(mcp.NewTool("update_ticket",
mcp.WithDescription("Update ticket title, description, and/or status"),
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
mcp.WithString("title", mcp.Description("New title")),
mcp.WithString("description", mcp.Description("New description")),
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
before, err := cli.TicketStore().Get(id)
if err != nil {
return fail(err)
}
after := before
if v := req.GetString("title", ""); v != "" {
after.Title = v
}
if _, ok := req.GetArguments()["description"]; ok {
after.Description = req.GetString("description", "")
}
if v := req.GetString("status", ""); v != "" {
after.Status = ticket.Status(v)
}
if err := cli.TicketStore().Update(after); err != nil {
return fail(err)
}
return jsonResult(after)
})
s.AddTool(mcp.NewTool("move_ticket",
mcp.WithDescription("Move a ticket (MCP only allows same project id)"),
mcp.WithString("id", mcp.Required()),
mcp.WithString("to_project_id", mcp.Required()),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
to, err := req.RequireString("to_project_id")
if err != nil {
return fail(err)
}
moved, err := cli.TicketStore().Move(id, to)
if err != nil {
return fail(err)
}
return jsonResult(moved)
})
s.AddTool(mcp.NewTool("delete_ticket",
mcp.WithDescription("Delete a ticket by id"),
mcp.WithString("id", mcp.Required()),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
id, err := req.RequireString("id")
if err != nil {
return fail(err)
}
if err := cli.TicketStore().Delete(id); err != nil {
return fail(err)
}
return mcp.NewToolResultText("deleted"), nil
})
s.AddTool(mcp.NewTool("list_time",
mcp.WithDescription("List time entries for the token project"),
mcp.WithString("ticket_id", mcp.Description("Optional ticket filter")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
ticketID := req.GetString("ticket_id", "")
var list []ticket.TimeEntry
var err error
if ticketID != "" {
list, err = cli.TimeStore().ListByTicket(ticketID)
} else {
list, err = cli.TimeStore().List()
}
if err != nil {
return fail(err)
}
return jsonResult(list)
})
s.AddTool(mcp.NewTool("add_time",
mcp.WithDescription("Add a manual time entry"),
mcp.WithString("ticket_id", mcp.Required()),
mcp.WithNumber("minutes", mcp.Required(), mcp.Description("Duration in minutes")),
mcp.WithString("note", mcp.Description("Optional note")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
ticketID, err := req.RequireString("ticket_id")
if err != nil {
return fail(err)
}
mins, err := req.RequireFloat("minutes")
if err != nil {
return fail(err)
}
tk, err := cli.TicketStore().Get(ticketID)
if err != nil {
return fail(err)
}
dur := time.Duration(mins * float64(time.Minute))
end := time.Now()
start := end.Add(-dur)
entry := ticket.TimeEntry{
TicketID: ticketID,
ProjectID: tk.ProjectID,
Start: start,
End: end,
Duration: dur,
Source: ticket.SourceManual,
Note: req.GetString("note", ""),
}
created, err := cli.TimeStore().Create(entry)
if err != nil {
return fail(err)
}
return jsonResult(created)
})
s.AddTool(mcp.NewTool("undo",
mcp.WithDescription("Undo the last daemon command"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
if err := cli.Executor().Undo(); err != nil {
return fail(err)
}
return mcp.NewToolResultText("ok"), nil
})
s.AddTool(mcp.NewTool("redo",
mcp.WithDescription("Redo the last undone daemon command"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
if err := cli.Executor().Redo(); err != nil {
return fail(err)
}
return mcp.NewToolResultText("ok"), nil
})
s.AddTool(mcp.NewTool("sync_pull",
mcp.WithDescription("Pull tickets/time from the configured backend for the token project"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
res, err := cli.SyncPull(tf.Project)
if err != nil {
return fail(err)
}
return jsonResult(res)
})
s.AddTool(mcp.NewTool("sync_push",
mcp.WithDescription("Push tickets/time to the configured backend for the token project"),
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_ = ctx
res, err := cli.SyncPush(tf.Project)
if err != nil {
return fail(err)
}
return jsonResult(res)
})
}
+79
View File
@@ -0,0 +1,79 @@
package mcp
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
const tokenFileName = ".tm_token"
// TokenFile holds credentials from a workspace .tm_token file.
type TokenFile struct {
Project string
Token string
Path string
}
// FindTokenFile walks from start (usually cwd) upward looking for .tm_token.
func FindTokenFile(start string) (TokenFile, error) {
dir, err := filepath.Abs(start)
if err != nil {
return TokenFile{}, err
}
for {
path := filepath.Join(dir, tokenFileName)
if st, err := os.Stat(path); err == nil && !st.IsDir() {
tf, err := ParseTokenFile(path)
if err != nil {
return TokenFile{}, err
}
return tf, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return TokenFile{}, fmt.Errorf("no %s found (create an MCP token in the TUI with g, then place project=… and token=… in %s)", tokenFileName, tokenFileName)
}
// ParseTokenFile reads project= and token= lines from path.
func ParseTokenFile(path string) (TokenFile, error) {
f, err := os.Open(path)
if err != nil {
return TokenFile{}, err
}
defer f.Close()
tf := TokenFile{Path: path}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(strings.ToLower(key))
val = strings.TrimSpace(val)
switch key {
case "project":
tf.Project = val
case "token":
tf.Token = val
}
}
if err := sc.Err(); err != nil {
return TokenFile{}, err
}
if tf.Project == "" || tf.Token == "" {
return TokenFile{}, fmt.Errorf("%s must contain project= and token= lines", path)
}
return tf, nil
}
+43
View File
@@ -0,0 +1,43 @@
package mcp
import (
"os"
"path/filepath"
"testing"
)
func TestParseAndFindTokenFile(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "a", "b")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, ".tm_token")
content := "# comment\nproject=inbox\ntoken=tm_mcp_abc\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
tf, err := FindTokenFile(sub)
if err != nil {
t.Fatal(err)
}
if tf.Project != "inbox" || tf.Token != "tm_mcp_abc" {
t.Fatalf("got %+v", tf)
}
_, err = FindTokenFile(t.TempDir())
if err == nil {
t.Fatal("expected missing token error")
}
}
func TestParseTokenFileIncomplete(t *testing.T) {
path := filepath.Join(t.TempDir(), ".tm_token")
if err := os.WriteFile(path, []byte("project=only\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ParseTokenFile(path); err == nil {
t.Fatal("expected error")
}
}
+179
View File
@@ -0,0 +1,179 @@
package project
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const metaFileName = "meta.json"
const (
BackendNone = "none"
BackendYouTrack = "youtrack"
BackendTaskMaster = "taskmaster"
)
// YouTrack holds remote sync settings for a project.
type YouTrack struct {
BaseURL string `json:"baseUrl"`
Token string `json:"token"`
ProjectKey string `json:"projectKey"`
// Query overrides the default "project: <ProjectKey>" issue filter.
Query string `json:"query,omitempty"`
}
// Configured reports whether enough fields are set to sync.
func (y YouTrack) Configured() bool {
return strings.TrimSpace(y.BaseURL) != "" &&
strings.TrimSpace(y.Token) != "" &&
strings.TrimSpace(y.ProjectKey) != ""
}
// IssueQuery returns the YouTrack search query used for sync.
func (y YouTrack) IssueQuery() string {
if q := strings.TrimSpace(y.Query); q != "" {
return q
}
return "project: " + strings.TrimSpace(y.ProjectKey)
}
// TaskMaster holds sync settings for a remote tm_server project.
type TaskMaster struct {
BaseURL string `json:"baseUrl"`
Token string `json:"token"`
ProjectID string `json:"projectId"`
}
// Configured reports whether task-master sync can run.
func (t TaskMaster) Configured() bool {
return strings.TrimSpace(t.BaseURL) != "" &&
strings.TrimSpace(t.Token) != "" &&
strings.TrimSpace(t.ProjectID) != ""
}
// McpAuth holds the hashed local MCP token for a project (plaintext never stored).
type McpAuth struct {
TokenHash string `json:"tokenHash"`
CreatedAt string `json:"createdAt,omitempty"`
}
// Meta is per-project metadata stored beside tickets.json.
type Meta struct {
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
YouTrack *YouTrack `json:"youtrack,omitempty"`
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
McpAuth *McpAuth `json:"mcpAuth,omitempty"`
}
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
func (m Meta) EffectiveBackend() string {
b := strings.TrimSpace(strings.ToLower(m.Backend))
switch b {
case BackendYouTrack, BackendTaskMaster, BackendNone:
return b
}
if m.YouTrack != nil && m.YouTrack.Configured() {
return BackendYouTrack
}
if m.TaskMaster != nil && m.TaskMaster.Configured() {
return BackendTaskMaster
}
if m.YouTrack != nil {
return BackendYouTrack
}
if m.TaskMaster != nil {
return BackendTaskMaster
}
return BackendNone
}
// SyncConfigured reports whether the active backend is fully set up.
func (m Meta) SyncConfigured() bool {
switch m.EffectiveBackend() {
case BackendYouTrack:
return m.YouTrack != nil && m.YouTrack.Configured()
case BackendTaskMaster:
return m.TaskMaster != nil && m.TaskMaster.Configured()
default:
return false
}
}
func (s *DirStore) metaPath(id string) string {
return filepath.Join(s.projectsRoot(), id, metaFileName)
}
// LoadMeta reads projects/<id>/meta.json. Missing file yields empty Meta.
func (s *DirStore) LoadMeta(id string) (Meta, error) {
if id == "" {
return Meta{}, fmt.Errorf("project id is required")
}
path := s.metaPath(id)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Meta{}, nil
}
return Meta{}, fmt.Errorf("read meta %s: %w", id, err)
}
if len(data) == 0 {
return Meta{}, nil
}
var meta Meta
if err := json.Unmarshal(data, &meta); err != nil {
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
}
if meta.Backend == "" && meta.YouTrack != nil {
meta.Backend = BackendYouTrack
}
if meta.Backend == "" && meta.TaskMaster != nil {
meta.Backend = BackendTaskMaster
}
return meta, nil
}
// SaveMeta writes projects/<id>/meta.json with mode 0600 (may contain tokens).
func (s *DirStore) SaveMeta(id string, meta Meta) error {
if id == "" {
return fmt.Errorf("project id is required")
}
if err := s.Ensure(id); err != nil {
return err
}
if meta.Backend == "" {
meta.Backend = meta.EffectiveBackend()
}
path := s.metaPath(id)
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("marshal meta: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), "meta-*.tmp")
if err != nil {
return fmt.Errorf("create temp meta: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod temp meta: %w", err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp meta: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp meta: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename meta: %w", err)
}
_ = os.Chmod(path, 0o600)
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package project_test
import (
"os"
"path/filepath"
"testing"
"task/internal/project"
)
func TestMetaSaveLoad(t *testing.T) {
dir := t.TempDir()
store := project.NewDirStore(dir)
if err := store.Ensure("inbox"); err != nil {
t.Fatal(err)
}
meta := project.Meta{
YouTrack: &project.YouTrack{
BaseURL: "https://yt.example.com",
Token: "perm:secret",
ProjectKey: "PROJ",
},
}
if err := store.SaveMeta("inbox", meta); err != nil {
t.Fatalf("SaveMeta: %v", err)
}
path := filepath.Join(dir, "projects", "inbox", "meta.json")
st, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if st.Mode().Perm()&0o077 != 0 {
t.Fatalf("meta permissions too open: %o", st.Mode().Perm())
}
got, err := store.LoadMeta("inbox")
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if got.YouTrack == nil || got.YouTrack.Token != "perm:secret" || got.YouTrack.ProjectKey != "PROJ" {
t.Fatalf("got %+v", got.YouTrack)
}
}
func TestLoadMetaMissing(t *testing.T) {
store := project.NewDirStore(t.TempDir())
got, err := store.LoadMeta("missing")
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if got.YouTrack != nil {
t.Fatalf("expected empty meta, got %+v", got)
}
}
func TestYouTrackConfiguredAndQuery(t *testing.T) {
y := project.YouTrack{BaseURL: "https://x", Token: "t", ProjectKey: "ABC"}
if !y.Configured() {
t.Fatal("expected configured")
}
if y.IssueQuery() != "project: ABC" {
t.Fatalf("query = %q", y.IssueQuery())
}
y.Query = "Assignee: me"
if y.IssueQuery() != "Assignee: me" {
t.Fatalf("query = %q", y.IssueQuery())
}
}
+4 -2
View File
@@ -2,6 +2,8 @@ package project
// Project is a lightweight workspace identified by a directory name.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
ID string `json:"id"`
Name string `json:"name"`
Code string `json:"code,omitempty"`
GroupID string `json:"groupId,omitempty"`
}
+25
View File
@@ -11,6 +11,9 @@ import (
type Store interface {
List() ([]Project, error)
Ensure(id string) error
Delete(id string) error
LoadMeta(id string) (Meta, error)
SaveMeta(id string, meta Meta) error
}
// DirStore discovers projects as subdirectories of dataDir/projects.
@@ -63,3 +66,25 @@ func (s *DirStore) Ensure(id string) error {
}
return nil
}
// Delete removes the project directory and all of its files.
func (s *DirStore) Delete(id string) error {
if id == "" {
return fmt.Errorf("project id is required")
}
dir := filepath.Join(s.projectsRoot(), id)
st, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("project %s: not found", id)
}
return fmt.Errorf("stat project %s: %w", id, err)
}
if !st.IsDir() {
return fmt.Errorf("project %s: not a directory", id)
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("delete project %s: %w", id, err)
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package project_test
import (
"os"
"path/filepath"
"testing"
"task/internal/project"
)
func TestDirStoreDelete(t *testing.T) {
dir := t.TempDir()
store := project.NewDirStore(dir)
if err := store.Ensure("inbox"); err != nil {
t.Fatal(err)
}
if err := store.Ensure("client"); err != nil {
t.Fatal(err)
}
// Drop a marker file so RemoveAll has something to wipe.
marker := filepath.Join(dir, "projects", "client", "tickets.json")
if err := os.WriteFile(marker, []byte("[]\n"), 0o644); err != nil {
t.Fatal(err)
}
list, err := store.List()
if err != nil || len(list) != 2 {
t.Fatalf("list before: %v len=%d", err, len(list))
}
if err := store.Delete("client"); err != nil {
t.Fatal(err)
}
list, err = store.List()
if err != nil || len(list) != 1 || list[0].ID != "inbox" {
t.Fatalf("list after: %v %+v", err, list)
}
if _, err := os.Stat(filepath.Join(dir, "projects", "client")); !os.IsNotExist(err) {
t.Fatalf("client dir still present: %v", err)
}
if err := store.Delete("missing"); err == nil {
t.Fatal("expected error deleting missing project")
}
}
+275
View File
@@ -0,0 +1,275 @@
package server
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"unicode"
"task/internal/sqlite"
)
const maxAttachmentBytes = 10 << 20 // 10 MiB
func (s *Server) attachmentsRoot() string {
root := strings.TrimSpace(s.DataDir)
if root == "" {
root = "."
}
return filepath.Join(root, "attachments")
}
func (s *Server) attachmentPath(ticketID, attachmentID string) string {
return filepath.Join(s.attachmentsRoot(), ticketID, attachmentID)
}
func (s *Server) removeTicketAttachmentDir(ticketID string) {
_ = os.RemoveAll(filepath.Join(s.attachmentsRoot(), ticketID))
}
func sanitizeFilename(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == ".." {
return "file"
}
var b strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
case r == '.', r == '-', r == '_', r == ' ':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := strings.TrimSpace(b.String())
if out == "" || out == "." || out == ".." {
return "file"
}
return out
}
func (s *Server) loadTicketAttachment(projectID, ticketID, attachmentID string) (sqlite.Attachment, error) {
t, err := s.Tickets.Get(ticketID)
if err != nil || t.ProjectID != projectID {
return sqlite.Attachment{}, fmt.Errorf("ticket not found")
}
a, err := s.Attachments.Get(attachmentID)
if err != nil || a.TicketID != ticketID || a.ProjectID != projectID {
return sqlite.Attachment{}, fmt.Errorf("attachment not found")
}
return a, nil
}
func (s *Server) handleListAttachments(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
list, err := s.Attachments.ListByTicket(tid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+512)
if err := r.ParseMultipartForm(maxAttachmentBytes + 512); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file")
return
}
defer file.Close()
if hdr.Size > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
filename := sanitizeFilename(hdr.Filename)
contentType := hdr.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
a, err := s.Attachments.Create(sqlite.Attachment{
TicketID: tid,
ProjectID: id,
Filename: filename,
ContentType: contentType,
SizeBytes: 0,
CreatedBy: u.ID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
dir := filepath.Join(s.attachmentsRoot(), tid)
if err := os.MkdirAll(dir, 0o755); err != nil {
_ = s.Attachments.Delete(a.ID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
path := s.attachmentPath(tid, a.ID)
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
_ = s.Attachments.Delete(a.ID)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
n, copyErr := io.Copy(dst, io.LimitReader(file, maxAttachmentBytes+1))
closeErr := dst.Close()
if copyErr != nil || closeErr != nil || n > maxAttachmentBytes {
_ = os.Remove(path)
_ = s.Attachments.Delete(a.ID)
if n > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
if copyErr != nil {
writeError(w, http.StatusInternalServerError, copyErr.Error())
return
}
writeError(w, http.StatusInternalServerError, closeErr.Error())
return
}
_ = s.Attachments.UpdateSize(a.ID, n)
a.SizeBytes = n
writeJSON(w, http.StatusCreated, a)
}
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
a, err := s.loadTicketAttachment(id, tid, aid)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
path := s.attachmentPath(tid, aid)
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusNotFound, "file missing")
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, strings.ReplaceAll(a.Filename, `"`, "")))
w.Header().Set("Content-Length", fmt.Sprintf("%d", st.Size()))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
func (s *Server) handlePutAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
a, err := s.loadTicketAttachment(id, tid, aid)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+1)
data, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
if int64(len(data)) > maxAttachmentBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
return
}
path := s.attachmentPath(tid, aid)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
size := int64(len(data))
if err := s.Attachments.UpdateSize(aid, size); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.SizeBytes = size
ct := r.Header.Get("Content-Type")
if ct != "" && ct != "application/octet-stream" {
a.ContentType = ct
}
writeJSON(w, http.StatusOK, a)
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
aid := r.PathValue("aid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, err := s.loadTicketAttachment(id, tid, aid); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
_ = os.Remove(s.attachmentPath(tid, aid))
if err := s.Attachments.Delete(aid); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+171
View File
@@ -0,0 +1,171 @@
package server
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestAttachmentUploadListDownloadPutDelete(t *testing.T) {
tmp := t.TempDir()
db, err := sqlite.Open(filepath.Join(tmp, "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(tmp, "data")
srv := New(db)
srv.DataDir = dataDir
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("file", "notes.txt")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("hello attach")); err != nil {
t.Fatal(err)
}
_ = mw.Close()
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("upload %d %s", rec.Code, rec.Body.String())
}
var att map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &att)
aid, _ := att["id"].(string)
if aid == "" {
t.Fatal("no attachment id")
}
if att["filename"] != "notes.txt" {
t.Fatalf("filename %v", att["filename"])
}
if int64(att["sizeBytes"].(float64)) != 12 {
t.Fatalf("size %v", att["sizeBytes"])
}
diskPath := filepath.Join(dataDir, "attachments", tid, aid)
if _, err := os.Stat(diskPath); err != nil {
t.Fatalf("disk file: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list %d %s", rec.Code, rec.Body.String())
}
var list []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 1 {
t.Fatalf("list len %d", len(list))
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("download %d %s", rec.Code, rec.Body.String())
}
got, _ := io.ReadAll(rec.Body)
if string(got) != "hello attach" {
t.Fatalf("download body %q", got)
}
putBody := []byte("updated content!!")
req = httptest.NewRequest(http.MethodPut, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, bytes.NewReader(putBody))
req.Header.Set("Content-Type", "text/plain")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("put %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &att)
if int64(att["sizeBytes"].(float64)) != int64(len(putBody)) {
t.Fatalf("put size %v", att["sizeBytes"])
}
disk, err := os.ReadFile(diskPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(disk, putBody) {
t.Fatalf("disk after put %q", disk)
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete %d %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(diskPath); !os.IsNotExist(err) {
t.Fatalf("file should be gone: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("list after delete %d", len(list))
}
}
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"net/http"
"strings"
"time"
"task/internal/sqlite"
)
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Username = strings.TrimSpace(body.Username)
u, err := s.DB.GetUserByUsername(body.Username)
if err != nil || !sqlite.CheckPassword(body.Password, u.PasswordHash) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, exp, err := s.DB.CreateSession(u.ID, 7*24*time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "session failed")
return
}
s.setSessionCookie(w, token, exp)
u.PasswordHash = ""
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
_ = s.DB.DeleteSession(c.Value)
}
s.clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.DB.ListUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
u, err := s.DB.CreateUser(body.Username, body.Password, body.IsAdmin)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
u.PasswordHash = ""
writeJSON(w, http.StatusCreated, u)
}
+60
View File
@@ -0,0 +1,60 @@
package server
import (
"net/http"
)
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.DB.ListProjectGroups()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, groups)
}
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
g, err := s.DB.CreateProjectGroup(body.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, g)
}
func (s *Server) handlePatchGroup(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.DB.RenameProjectGroup(id, body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
g, err := s.DB.GetProjectGroup(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, g)
}
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.DB.DeleteProjectGroup(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+72
View File
@@ -0,0 +1,72 @@
package server
import (
"fmt"
"task/internal/project"
"task/internal/ticket"
)
// ImportJSONDataDir copies projects/tickets/time from a local JSON DataDir into SQLite.
func (s *Server) ImportJSONDataDir(dataDir string, ownerUserID string) error {
ps := project.NewDirStore(dataDir)
ts := ticket.NewJSONStore(dataDir)
timeStore := ticket.NewJSONTimeStore(dataDir)
projects, err := ps.List()
if err != nil {
return err
}
for _, p := range projects {
if err := s.Projects.Ensure(p.ID); err != nil {
return fmt.Errorf("project %s: %w", p.ID, err)
}
if ownerUserID != "" {
_ = s.DB.SetMember(p.ID, ownerUserID, "owner")
}
meta, err := ps.LoadMeta(p.ID)
if err != nil {
return err
}
if meta.YouTrack != nil && meta.YouTrack.Configured() {
if err := s.Projects.SaveMeta(p.ID, meta); err != nil {
return fmt.Errorf("meta %s: %w", p.ID, err)
}
}
tickets, err := ts.List(p.ID)
if err != nil {
return err
}
for _, t := range tickets {
if _, err := s.Tickets.Get(t.ID); err == nil {
continue // already imported
}
if _, err := s.Tickets.Create(t); err != nil {
return fmt.Errorf("ticket %s: %w", t.ID, err)
}
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "import", ownerUserID, t)
}
}
entries, err := timeStore.List()
if err != nil {
return err
}
existing, _ := s.Times.List()
have := map[string]bool{}
for _, e := range existing {
have[e.ID] = true
}
for _, e := range entries {
if have[e.ID] {
continue
}
if _, err := s.Times.Create(e); err != nil {
return fmt.Errorf("time %s: %w", e.ID, err)
}
}
if active, err := timeStore.LoadActiveTimer(); err == nil && active != nil {
_ = s.Times.SaveActiveTimer(active)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"os"
"path/filepath"
"testing"
"task/internal/project"
"task/internal/sqlite"
"task/internal/ticket"
)
func TestImportJSONDataDir(t *testing.T) {
dataDir := t.TempDir()
ps := project.NewDirStore(dataDir)
if err := ps.Ensure("inbox"); err != nil {
t.Fatal(err)
}
ts := ticket.NewJSONStore(dataDir)
created, err := ts.Create(ticket.Ticket{Title: "From JSON", ProjectID: "inbox"})
if err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(t.TempDir(), "import.db")
db, err := sqlite.Open(dbPath, []byte("sec"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
u, err := db.CreateUser("admin", "x", true)
if err != nil {
t.Fatal(err)
}
srv := New(db)
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
t.Fatal(err)
}
got, err := srv.Tickets.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "From JSON" {
t.Fatalf("title=%q", got.Title)
}
role, _ := db.MemberRole("inbox", u.ID)
if role != sqlite.RoleOwner {
t.Fatalf("role=%s", role)
}
// second import is idempotent
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
t.Fatal(err)
}
_ = os.RemoveAll(dataDir) // ensure we don't rely on JSON after import for this test cleanup
}
+23
View File
@@ -0,0 +1,23 @@
package server
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(r *http.Request, dest any) error {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dest)
}
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"context"
"net/http"
"strings"
"task/internal/sqlite"
)
type ctxKey int
const (
userKey ctxKey = 1
tokenAuthKey ctxKey = 2
)
func (s *Server) currentUser(r *http.Request) (sqlite.User, bool) {
u, ok := r.Context().Value(userKey).(sqlite.User)
return u, ok
}
func (s *Server) currentTokenAuth(r *http.Request) (sqlite.TokenAuth, bool) {
t, ok := r.Context().Value(tokenAuthKey).(sqlite.TokenAuth)
return t, ok
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := s.authenticate(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r.WithContext(ctx))
}
}
func (s *Server) authenticate(r *http.Request) (context.Context, bool) {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
raw := strings.TrimSpace(auth[len("Bearer "):])
ta, err := s.DB.ResolveBearerToken(raw)
if err != nil {
return r.Context(), false
}
ctx := context.WithValue(r.Context(), userKey, ta.User)
ctx = context.WithValue(ctx, tokenAuthKey, ta)
return ctx, true
}
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return r.Context(), false
}
u, err := s.DB.UserFromSession(c.Value)
if err != nil {
return r.Context(), false
}
u.PasswordHash = ""
return context.WithValue(r.Context(), userKey, u), true
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "admin required")
return
}
u, _ := s.currentUser(r)
if !u.IsAdmin {
writeError(w, http.StatusForbidden, "admin required")
return
}
next(w, r)
})
}
func (s *Server) requireProjectRole(projectID string, minRole string, r *http.Request) (sqlite.User, bool, int) {
u, ok := s.currentUser(r)
if !ok {
return sqlite.User{}, false, http.StatusUnauthorized
}
if ta, ok := s.currentTokenAuth(r); ok {
if ta.ProjectID != projectID {
return u, false, http.StatusForbidden
}
need := sqlite.TokenScopeRead
if minRole == sqlite.RoleMember || minRole == sqlite.RoleAdmin || minRole == sqlite.RoleOwner {
need = sqlite.TokenScopeWrite
}
// viewer → read; member+ → write
if minRole == sqlite.RoleViewer {
need = sqlite.TokenScopeRead
}
if !sqlite.TokenAllows(ta.Scope, need) {
return u, false, http.StatusForbidden
}
return u, true, 0
}
if u.IsAdmin {
return u, true, 0
}
role, err := s.DB.MemberRole(projectID, u.ID)
if err != nil || role == "" {
return u, false, http.StatusForbidden
}
if !sqlite.RoleAtLeast(role, minRole) {
return u, false, http.StatusForbidden
}
return u, true, 0
}
+160
View File
@@ -0,0 +1,160 @@
package server
import (
"net/http"
"strings"
"task/internal/sqlite"
)
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
Name string `json:"name"`
GroupID string `json:"groupId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Name = strings.TrimSpace(body.Name)
if body.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
p, err := s.Projects.Create("", body.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
if gid := strings.TrimSpace(body.GroupID); gid != "" {
if err := s.Projects.SetGroup(p.ID, gid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
p.GroupID = gid
}
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
p, err := s.Projects.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Name *string `json:"name"`
GroupID *string `json:"groupId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Name != nil {
if err := s.Projects.Rename(id, *body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if body.GroupID != nil {
if err := s.Projects.SetGroup(id, strings.TrimSpace(*body.GroupID)); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
p, err := s.Projects.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleOwner, r); !ok {
writeError(w, code, "forbidden")
return
}
_ = s.Times.DeleteByProject(id)
if err := s.Projects.Delete(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
members, err := s.DB.ListMembers(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
userID := r.PathValue("userId")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Role string `json:"role"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.DB.SetMember(id, userID, body.Role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
userID := r.PathValue("userId")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if err := s.DB.RemoveMember(id, userID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+108
View File
@@ -0,0 +1,108 @@
package server
import (
"net/http"
"sort"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
groupBy := ticket.GroupBy(r.URL.Query().Get("groupBy"))
if groupBy == "" {
groupBy = ticket.GroupByTicket
}
valid := false
for _, g := range ticket.AllGroupBys {
if g == groupBy {
valid = true
break
}
}
if !valid {
writeError(w, http.StatusBadRequest, "invalid groupBy")
return
}
from, err := ticket.ParseReportDay(r.URL.Query().Get("from"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
toDay, err := ticket.ParseReportDay(r.URL.Query().Get("to"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var toBound = toDay
if !toDay.IsZero() {
toBound = ticket.EndOfReportDay(toDay)
}
filter := ticket.TimeFilter{
From: from,
To: toBound,
TicketID: r.URL.Query().Get("ticketId"),
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
entries = ticket.FilterEntries(entries, filter)
sort.Slice(entries, func(i, j int) bool {
ai, aj := entries[i].Start, entries[j].Start
if ai.IsZero() {
ai = entries[i].End
}
if aj.IsZero() {
aj = entries[j].End
}
if !ai.Equal(aj) {
return ai.After(aj)
}
return entries[i].ID > entries[j].ID
})
tickets, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
byID := make(map[string]ticket.Ticket, len(tickets))
for _, t := range tickets {
byID[t.ID] = t
}
rows := ticket.Aggregate(entries, byID, groupBy)
total := ticket.TotalDuration(entries)
type rowJSON struct {
Key string `json:"key"`
Label string `json:"label"`
DurationNs int64 `json:"durationNs"`
}
out := make([]rowJSON, 0, len(rows))
for _, row := range rows {
out = append(out, rowJSON{
Key: row.Key,
Label: row.Label,
DurationNs: int64(row.Duration),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"groupBy": groupBy,
"totalDurationNs": int64(total),
"rows": out,
"entryCount": len(entries),
"entries": entries,
"from": r.URL.Query().Get("from"),
"to": r.URL.Query().Get("to"),
"ticketId": filter.TicketID,
})
}
+109
View File
@@ -0,0 +1,109 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func TestProjectReportFilters(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var tk map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &tk)
tid, _ := tk["id"].(string)
day := time.Date(2026, 10, 18, 14, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: day,
End: day.Add(45 * time.Minute),
Duration: 45 * time.Minute,
Source: ticket.SourceManual,
Note: "filtered",
})
if err != nil {
t.Fatal(err)
}
other := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: other,
End: other.Add(time.Hour),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
url := "/api/v1/projects/" + pid + "/report?groupBy=ticket&from=2026-10-18&to=2026-10-18&ticketId=" + tid
req = httptest.NewRequest(http.MethodGet, url, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("report %d %s", rec.Code, rec.Body.String())
}
var out struct {
EntryCount int `json:"entryCount"`
TotalDurationNs int64 `json:"totalDurationNs"`
Entries []ticket.TimeEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.EntryCount != 1 || len(out.Entries) != 1 {
t.Fatalf("want 1 entry, got count=%d entries=%d", out.EntryCount, len(out.Entries))
}
if out.TotalDurationNs != int64(45*time.Minute) {
t.Fatalf("total=%d", out.TotalDurationNs)
}
if out.Entries[0].Note != "filtered" {
t.Fatalf("note=%q", out.Entries[0].Note)
}
}
+270
View File
@@ -0,0 +1,270 @@
package server
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"task/internal/config"
"task/internal/project"
"task/internal/sqlite"
"task/internal/ticket"
)
// SeedDemoOptions controls optional client wiring during demo seed.
type SeedDemoOptions struct {
// ClientDataDir, when set, writes local TUI projects (meta.json + tokens)
// for "Test" and "Website Relaunch" under that data directory.
ClientDataDir string
// BaseURL is the tm_server URL stored in client meta (default http://127.0.0.1:8080).
BaseURL string
}
// tuiSeedProjects are server project names that get a write token and local TUI wiring.
var tuiSeedProjects = []string{"Test", "Website Relaunch"}
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
func (s *Server) SeedDemo(opts SeedDemoOptions) error {
projects, err := s.Projects.List()
if err != nil {
return err
}
if len(projects) > 0 {
return nil
}
users, err := s.DB.ListUsers()
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("seed: no users (bootstrap admin first)")
}
admin := users[0]
for _, u := range users {
if u.IsAdmin {
admin = u
break
}
}
member, err := s.DB.CreateUser("alice", "alice", false)
if err != nil {
member, err = s.DB.GetUserByUsername("alice")
if err != nil {
return fmt.Errorf("seed alice: %w", err)
}
}
type seedTicket struct {
Title, Desc string
Status ticket.Status
}
demos := []struct {
Name string
Tickets []seedTicket
}{
{
Name: "Test",
Tickets: []seedTicket{
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
{Title: "TUI Sync", Desc: "Lokal unter `.dev-home` schon mit Token verdrahtet — in `tm` (`make run-dev`) Pull/Push testen.", Status: ticket.StatusTodo},
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
},
},
{
Name: "Website Relaunch",
Tickets: []seedTicket{
{Title: "Landing Hero entwerfen", Desc: "Dunkles Layout, klare CTAs.\n\n- [ ] Moodboard\n- [ ] Wireframe", Status: ticket.StatusInProgress},
{Title: "CMS-Inhalte migrieren", Desc: "Alte Seiten nach Markdown überführen.", Status: ticket.StatusTodo},
{Title: "Impressum & Datenschutz", Desc: "Rechtstexte finalisieren.", Status: ticket.StatusDone},
},
},
{
Name: "API Platform",
Tickets: []seedTicket{
{Title: "Auth-Middleware härten", Desc: "Session-Cookies dokumentieren.", Status: ticket.StatusInProgress},
{Title: "Rate-Limits festlegen", Desc: "Pro User und pro IP.", Status: ticket.StatusTodo},
{Title: "Health-Check", Desc: "Bereits live.", Status: ticket.StatusDone},
},
},
}
for i := len(demos) + 1; i <= 50; i++ {
demos = append(demos, struct {
Name string
Tickets []seedTicket
}{
Name: fmt.Sprintf("Sample %02d", i),
Tickets: []seedTicket{
{Title: "Starter ticket", Desc: "Seed-Platzhalter.", Status: ticket.StatusTodo},
},
})
}
now := time.Now().UTC()
created := make([]struct{ ID, Name string }, 0, len(demos))
for _, d := range demos {
p, err := s.Projects.Create("", d.Name)
if err != nil {
return err
}
created = append(created, struct{ ID, Name string }{p.ID, p.Name})
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
// Explicit local-only sync (no remote backend in the test environment).
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
ProjectID: p.ID,
Kind: "none",
})
var firstID string
for i, st := range d.Tickets {
t, err := s.Tickets.Create(ticket.Ticket{
Title: st.Title,
Description: st.Desc,
Status: st.Status,
ProjectID: p.ID,
})
if err != nil {
return err
}
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "seed", admin.ID, t)
if i == 0 {
firstID = t.ID
}
}
if firstID != "" {
_, err := s.Times.Create(ticket.TimeEntry{
TicketID: firstID,
ProjectID: p.ID,
Start: now.Add(-2 * time.Hour),
End: now.Add(-90 * time.Minute),
Duration: 30 * time.Minute,
Source: ticket.SourceManual,
Note: "seed",
})
if err != nil {
return err
}
}
}
kunden, err := s.DB.CreateProjectGroup("Kunden")
if err != nil {
return fmt.Errorf("seed group Kunden: %w", err)
}
intern, err := s.DB.CreateProjectGroup("Intern")
if err != nil {
return fmt.Errorf("seed group Intern: %w", err)
}
for _, c := range created {
switch {
case c.Name == "Website Relaunch" || c.Name == "API Platform" || strings.HasPrefix(c.Name, "Sample 0"):
_ = s.Projects.SetGroup(c.ID, kunden.ID)
case c.Name == "Test" || strings.HasPrefix(c.Name, "Sample 1") || strings.HasPrefix(c.Name, "Sample 2"):
_ = s.Projects.SetGroup(c.ID, intern.ID)
}
}
fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
if dir := strings.TrimSpace(opts.ClientDataDir); dir != "" {
if err := s.wireDemoTUI(dir, opts.BaseURL, created, admin.ID); err != nil {
return fmt.Errorf("seed tui: %w", err)
}
}
return nil
}
func (s *Server) wireDemoTUI(dataDir, baseURL string, created []struct{ ID, Name string }, adminID string) error {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = "http://127.0.0.1:8080"
}
byName := make(map[string]string, len(created))
for _, c := range created {
byName[c.Name] = c.ID
}
store := project.NewDirStore(dataDir)
wired := make([]string, 0, len(tuiSeedProjects))
for _, name := range tuiSeedProjects {
remoteID, ok := byName[name]
if !ok {
return fmt.Errorf("missing seeded project %q", name)
}
tok, err := s.DB.CreateProjectToken(remoteID, "dev-tui", sqlite.TokenScopeWrite, adminID)
if err != nil {
return fmt.Errorf("token for %s: %w", name, err)
}
// Fresh local dir so stale tickets from a previous DB wipe cannot confuse sync.
_ = os.RemoveAll(filepath.Join(dataDir, "projects", name))
meta := project.Meta{
Backend: project.BackendTaskMaster,
TaskMaster: &project.TaskMaster{
BaseURL: baseURL,
Token: tok.Token,
ProjectID: remoteID,
},
}
if err := store.SaveMeta(name, meta); err != nil {
return fmt.Errorf("meta %s: %w", name, err)
}
wired = append(wired, name)
fmt.Fprintf(os.Stderr, "tui wired: %s → %s (project %s, token %s…)\n",
name, baseURL, remoteID, trimTokenPrefix(tok.Token))
}
if err := setActiveProjectIfSameDataDir(dataDir, tuiSeedProjects[0]); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "tui ready: %s (make run-dev → Y pull)\n", strings.Join(wired, ", "))
return nil
}
func trimTokenPrefix(tok string) string {
if len(tok) <= 10 {
return tok
}
return tok[:10]
}
func setActiveProjectIfSameDataDir(dataDir, active string) error {
cfg, err := config.LoadOrCreate()
if err != nil {
return fmt.Errorf("config: %w", err)
}
if filepath.Clean(cfg.DataDir) != filepath.Clean(dataDir) {
return nil
}
cfg.ActiveProject = active
if err := config.Save(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// SeedBaseURL derives the public base URL for seeded TUI meta from -listen / env.
func SeedBaseURL(listen string) string {
if u := strings.TrimSpace(os.Getenv("TM_SEED_BASE_URL")); u != "" {
return strings.TrimRight(u, "/")
}
host := strings.TrimSpace(listen)
if host == "" {
host = ":8080"
}
switch {
case strings.HasPrefix(host, "http://"), strings.HasPrefix(host, "https://"):
return strings.TrimRight(host, "/")
case strings.HasPrefix(host, ":"):
return "http://127.0.0.1" + host
default:
return "http://" + host
}
}
+137
View File
@@ -0,0 +1,137 @@
package server
import (
"os"
"path/filepath"
"testing"
"task/internal/project"
"task/internal/sqlite"
)
func TestSeedDemo(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "seed.db"), []byte("s"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "admin", true); err != nil {
t.Fatal(err)
}
srv := New(db)
if err := srv.SeedDemo(SeedDemoOptions{}); err != nil {
t.Fatal(err)
}
projects, err := srv.Projects.List()
if err != nil || len(projects) != 50 {
t.Fatalf("want 50 projects, got %d err=%v", len(projects), err)
}
var testID string
for _, p := range projects {
if p.Name == "Test" {
testID = p.ID
break
}
}
if testID == "" {
t.Fatal("missing Test project")
}
groups, err := db.ListProjectGroups()
if err != nil || len(groups) != 2 {
t.Fatalf("want 2 groups, got %d err=%v", len(groups), err)
}
p, err := srv.Projects.Get(testID)
if err != nil || p.GroupID == "" {
t.Fatalf("Test project should be grouped, got %+v err=%v", p, err)
}
sync, err := srv.Projects.GetSyncBackend(testID)
if err != nil || sync.Kind != "none" {
t.Fatalf("sync=%+v err=%v", sync, err)
}
tickets, err := srv.Tickets.List(testID)
if err != nil || len(tickets) == 0 {
t.Fatalf("tickets empty")
}
conns, err := db.ListConnections()
if err != nil || len(conns) != 0 {
t.Fatalf("expected no remote connections, got %v", conns)
}
// idempotent when projects already exist
if err := srv.SeedDemo(SeedDemoOptions{}); err != nil {
t.Fatal(err)
}
projects2, _ := srv.Projects.List()
if len(projects2) != len(projects) {
t.Fatalf("re-seed changed project count")
}
}
func TestSeedDemoWiresTUI(t *testing.T) {
home := t.TempDir()
t.Setenv("TASK_MASTER_HOME", home)
dataDir := filepath.Join(home, ".local", "share", "task-master")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
db, err := sqlite.Open(filepath.Join(dataDir, "seed.db"), []byte("s"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "admin", true); err != nil {
t.Fatal(err)
}
srv := New(db)
if err := srv.SeedDemo(SeedDemoOptions{
ClientDataDir: dataDir,
BaseURL: "http://127.0.0.1:8080",
}); err != nil {
t.Fatal(err)
}
store := project.NewDirStore(dataDir)
for _, name := range tuiSeedProjects {
meta, err := store.LoadMeta(name)
if err != nil {
t.Fatalf("LoadMeta %s: %v", name, err)
}
if meta.EffectiveBackend() != project.BackendTaskMaster {
t.Fatalf("%s backend=%q", name, meta.EffectiveBackend())
}
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
t.Fatalf("%s taskmaster not configured: %+v", name, meta.TaskMaster)
}
if meta.TaskMaster.BaseURL != "http://127.0.0.1:8080" {
t.Fatalf("baseURL=%q", meta.TaskMaster.BaseURL)
}
auth, err := db.ResolveBearerToken(meta.TaskMaster.Token)
if err != nil {
t.Fatalf("token resolve %s: %v", name, err)
}
if auth.ProjectID != meta.TaskMaster.ProjectID {
t.Fatalf("token project mismatch")
}
if !sqlite.TokenAllows(auth.Scope, sqlite.TokenScopeWrite) {
t.Fatalf("want write scope, got %q", auth.Scope)
}
tokens, err := db.ListProjectTokens(meta.TaskMaster.ProjectID)
if err != nil || len(tokens) != 1 || tokens[0].Name != "dev-tui" {
t.Fatalf("tokens=%v err=%v", tokens, err)
}
}
}
func TestSeedBaseURL(t *testing.T) {
t.Setenv("TM_SEED_BASE_URL", "")
if got := SeedBaseURL(":8080"); got != "http://127.0.0.1:8080" {
t.Fatalf("got %q", got)
}
if got := SeedBaseURL("0.0.0.0:9000"); got != "http://0.0.0.0:9000" {
t.Fatalf("got %q", got)
}
t.Setenv("TM_SEED_BASE_URL", "http://localhost:8080/")
if got := SeedBaseURL(":9999"); got != "http://localhost:8080" {
t.Fatalf("env override got %q", got)
}
}
+188
View File
@@ -0,0 +1,188 @@
package server
import (
"io/fs"
"net/http"
"strings"
"time"
"task/internal/sqlite"
"task/internal/syncbackend"
"task/internal/webembed"
)
// Server is the HTTP API + embedded UI.
type Server struct {
DB *sqlite.DB
Projects *sqlite.ProjectStore
Tickets *sqlite.TicketStore
Times *sqlite.TimeStore
Attachments *sqlite.AttachmentStore
Sync *syncbackend.Registry
DataDir string // root for on-disk attachments (cfg.DataDir)
Secure bool // set Secure on cookies when behind TLS
Static fs.FS
}
// New constructs a server from an open DB.
func New(db *sqlite.DB) *Server {
return &Server{
DB: db,
Projects: sqlite.NewProjectStore(db),
Tickets: sqlite.NewTicketStore(db),
Times: sqlite.NewTimeStore(db),
Attachments: sqlite.NewAttachmentStore(db),
Sync: syncbackend.NewRegistry(),
Static: webembed.FS(),
}
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return withCORS(mux)
}
func (s *Server) routes(mux *http.ServeMux) {
api := http.NewServeMux()
api.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
api.HandleFunc("POST /auth/login", s.handleLogin)
api.HandleFunc("POST /auth/logout", s.requireAuth(s.handleLogout))
api.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
api.HandleFunc("GET /users", s.requireAdmin(s.handleListUsers))
api.HandleFunc("POST /users", s.requireAdmin(s.handleCreateUser))
api.HandleFunc("PATCH /users/{id}", s.requireAdmin(s.handlePatchUser))
api.HandleFunc("DELETE /users/{id}", s.requireAdmin(s.handleDeleteUser))
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
api.HandleFunc("GET /project-groups", s.requireAuth(s.handleListGroups))
api.HandleFunc("POST /project-groups", s.requireAuth(s.handleCreateGroup))
api.HandleFunc("PATCH /project-groups/{id}", s.requireAuth(s.handlePatchGroup))
api.HandleFunc("DELETE /project-groups/{id}", s.requireAuth(s.handleDeleteGroup))
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
api.HandleFunc("PATCH /projects/{id}", s.requireAuth(s.handlePatchProject))
api.HandleFunc("DELETE /projects/{id}", s.requireAuth(s.handleDeleteProject))
api.HandleFunc("GET /projects/{id}/members", s.requireAuth(s.handleListMembers))
api.HandleFunc("PUT /projects/{id}/members/{userId}", s.requireAuth(s.handleSetMember))
api.HandleFunc("DELETE /projects/{id}/members/{userId}", s.requireAuth(s.handleRemoveMember))
api.HandleFunc("GET /projects/{id}/tokens", s.requireAuth(s.handleListTokens))
api.HandleFunc("POST /projects/{id}/tokens", s.requireAuth(s.handleCreateToken))
api.HandleFunc("DELETE /projects/{id}/tokens/{tid}", s.requireAuth(s.handleRevokeToken))
api.HandleFunc("GET /projects/{id}/sync", s.requireAuth(s.handleGetSync))
api.HandleFunc("PUT /projects/{id}/sync", s.requireAuth(s.handlePutSync))
api.HandleFunc("POST /projects/{id}/sync/pull", s.requireAuth(s.handleSyncPull))
api.HandleFunc("POST /projects/{id}/sync/push", s.requireAuth(s.handleSyncPush))
api.HandleFunc("GET /projects/{id}/tickets", s.requireAuth(s.handleListTickets))
api.HandleFunc("POST /projects/{id}/tickets", s.requireAuth(s.handleCreateTicket))
api.HandleFunc("GET /projects/{id}/tickets/{tid}", s.requireAuth(s.handleGetTicket))
api.HandleFunc("PATCH /projects/{id}/tickets/{tid}", s.requireAuth(s.handlePatchTicket))
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleListAttachments))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleUploadAttachment))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleGetAttachment))
api.HandleFunc("PUT /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handlePutAttachment))
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleDeleteAttachment))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
api.HandleFunc("GET /projects/{id}/report", s.requireAuth(s.handleProjectReport))
api.HandleFunc("GET /projects/{id}/time", s.requireAuth(s.handleListProjectTime))
api.HandleFunc("POST /projects/{id}/time", s.requireAuth(s.handleCreateTime))
api.HandleFunc("PATCH /projects/{id}/time/{eid}", s.requireAuth(s.handleUpdateTime))
api.HandleFunc("DELETE /projects/{id}/time/{eid}", s.requireAuth(s.handleDeleteTime))
api.HandleFunc("GET /timer", s.requireAuth(s.handleGetTimer))
api.HandleFunc("POST /timer/start", s.requireAuth(s.handleStartTimer))
api.HandleFunc("POST /timer/stop", s.requireAuth(s.handleStopTimer))
api.HandleFunc("GET /connections", s.requireAdmin(s.handleListConnections))
api.HandleFunc("POST /connections", s.requireAdmin(s.handleCreateConnection))
api.HandleFunc("DELETE /connections/{id}", s.requireAdmin(s.handleDeleteConnection))
api.HandleFunc("GET /connections/{id}/projects", s.requireAdmin(s.handleConnectionProjects))
api.HandleFunc("POST /connections/{id}/import", s.requireAdmin(s.handleImportProject))
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
mux.Handle("/", s.spaHandler())
}
const sessionCookie = "tm_session"
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.Secure,
Expires: expires,
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.Secure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) spaHandler() http.Handler {
static := s.Static
if static == nil {
static = webembed.FS()
}
fileServer := http.FileServer(http.FS(static))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if f, err := static.Open(path); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback
r2 := r.Clone(r.Context())
r2.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r2)
})
}
+90
View File
@@ -0,0 +1,90 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestAPIAuthAndTickets(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()
if len(cookie) == 0 {
t.Fatal("no session cookie")
}
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie[0])
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
if pid == "" {
t.Fatal("expected auto project id")
}
ticketBody, _ := json.Marshal(map[string]string{"title": "First", "description": "hi"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie[0])
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
if tid == "" {
t.Fatal("no ticket id")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/revisions", nil)
req.AddCookie(cookie[0])
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("revisions %d", rec.Code)
}
var revs []any
_ = json.Unmarshal(rec.Body.Bytes(), &revs)
if len(revs) < 1 {
t.Fatal("expected revision")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health %d", rec.Code)
}
}
+237
View File
@@ -0,0 +1,237 @@
package server
import (
"net/http"
"strings"
"task/internal/sqlite"
"task/internal/syncbackend"
)
func (s *Server) handleGetSync(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
b, err := s.Projects.GetSyncBackend(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, b)
}
func (s *Server) handlePutSync(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
var body sqlite.SyncBackend
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.ProjectID = id
if body.Kind == "" {
body.Kind = "none"
}
if err := s.Projects.SetSyncBackend(body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, body)
}
func (s *Server) projectSyncConfig(projectID string) (syncbackend.ProjectConfig, error) {
b, err := s.Projects.GetSyncBackend(projectID)
if err != nil {
return syncbackend.ProjectConfig{}, err
}
cfg := syncbackend.ProjectConfig{
Kind: b.Kind,
BaseURL: b.BaseURL,
ProjectKey: b.ProjectKey,
Query: b.Query,
}
if b.ConnectionID != "" {
conn, err := s.DB.GetConnection(b.ConnectionID)
if err != nil {
return cfg, err
}
cfg.Token = conn.Token
if cfg.BaseURL == "" {
cfg.BaseURL = conn.BaseURL
}
}
return cfg, nil
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, true)
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
s.runSync(w, r, false)
}
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, pull bool) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
cfg, err := s.projectSyncConfig(id)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
backend, err := s.Sync.Get(cfg.Kind)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var res syncbackend.Result
if pull {
res, err = backend.Pull(r.Context(), id, cfg, s.Tickets, s.Times)
} else {
res, err = backend.Push(r.Context(), id, cfg, s.Tickets, s.Times)
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
op := "sync_push"
if pull {
op = "sync_pull"
}
tickets, _ := s.Tickets.List(id)
for _, t := range tickets {
_, _ = s.DB.AppendRevision("ticket", t.ID, id, op, u.ID, t)
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListConnections()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateConnection(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Scope string `json:"scope"`
Token string `json:"token"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Kind == "" {
body.Kind = "youtrack"
}
c, err := s.DB.CreateConnection(body.Name, body.Kind, body.BaseURL, body.Scope, body.Token, u.ID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, c)
}
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.DB.DeleteConnection(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleConnectionProjects(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
conn, err := s.DB.GetConnection(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
backend, err := s.Sync.Get(conn.Kind)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
projects, err := backend.ListProjects(r.Context(), syncbackend.Connection{
Kind: conn.Kind, BaseURL: conn.BaseURL, Token: conn.Token, Scope: conn.Scope,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleImportProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
connID := r.PathValue("id")
var body struct {
RemoteKey string `json:"remoteKey"`
LocalID string `json:"localId"`
Name string `json:"name"`
Pull bool `json:"pull"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.RemoteKey = strings.TrimSpace(body.RemoteKey)
if body.RemoteKey == "" {
writeError(w, http.StatusBadRequest, "remoteKey required")
return
}
conn, err := s.DB.GetConnection(connID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
name := body.Name
if name == "" {
name = body.RemoteKey
}
// LocalID optional; empty → auto UUID. Explicit id only for advanced clients.
p, err := s.Projects.Create(strings.TrimSpace(body.LocalID), name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
ProjectID: p.ID,
Kind: conn.Kind,
BaseURL: conn.BaseURL,
ProjectKey: body.RemoteKey,
ConnectionID: conn.ID,
})
result := map[string]any{"project": p}
if body.Pull {
cfg, err := s.projectSyncConfig(p.ID)
if err == nil {
if backend, err := s.Sync.Get(cfg.Kind); err == nil {
res, err := backend.Pull(r.Context(), p.ID, cfg, s.Tickets, s.Times)
if err != nil {
result["pullError"] = err.Error()
} else {
result["pull"] = res
}
}
}
}
writeJSON(w, http.StatusCreated, result)
}
+235
View File
@@ -0,0 +1,235 @@
package server
import (
"encoding/json"
"net/http"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
list, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Title string `json:"title"`
Description string `json:"description"`
Status ticket.Status `json:"status"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
t, err := s.Tickets.Create(ticket.Ticket{
Title: body.Title,
Description: body.Description,
Status: body.Status,
ProjectID: id,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "create", u.ID, t)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handlePatchTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
var body struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *ticket.Status `json:"status"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Title != nil {
t.Title = *body.Title
}
if body.Description != nil {
t.Description = *body.Description
}
if body.Status != nil {
t.Status = *body.Status
}
if err := s.Tickets.Update(t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
t, _ = s.Tickets.Get(tid)
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "update", u.ID, t)
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "delete", u.ID, t)
if err := s.Tickets.Delete(tid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.removeTicketAttachmentDir(tid)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
ToProjectID string `json:"toProjectId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, ok, code := s.requireProjectRole(body.ToProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden target project")
return
}
t, err := s.Tickets.Get(tid)
if err != nil || t.ProjectID != id {
writeError(w, http.StatusNotFound, "ticket not found")
return
}
moved, err := s.Tickets.Move(tid, body.ToProjectID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.Times.RetargetTicket(tid, body.ToProjectID)
_, _ = s.DB.AppendRevision("ticket", moved.ID, body.ToProjectID, "move", u.ID, moved)
writeJSON(w, http.StatusOK, moved)
}
func (s *Server) handleListTicketRevisions(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
revs, err := s.DB.ListRevisions("ticket", tid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, revs)
}
func (s *Server) handleRestoreTicketRevision(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
rid := r.PathValue("rid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var revID int64
if _, err := parseInt64(rid, &revID); err != nil {
writeError(w, http.StatusBadRequest, "invalid revision id")
return
}
rev, err := s.DB.GetRevision(revID)
if err != nil || rev.EntityID != tid {
writeError(w, http.StatusNotFound, "revision not found")
return
}
var snap ticket.Ticket
if err := json.Unmarshal(rev.Snapshot, &snap); err != nil {
writeError(w, http.StatusInternalServerError, "bad snapshot")
return
}
snap.ID = tid
snap.ProjectID = id
if err := s.Tickets.Update(snap); err != nil {
// recreate if deleted
if _, cerr := s.Tickets.Create(snap); cerr != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
t, _ := s.Tickets.Get(tid)
_, _ = s.DB.AppendRevision("ticket", tid, id, "restore", u.ID, t)
writeJSON(w, http.StatusOK, t)
}
func parseInt64(s string, dest *int64) (int64, error) {
var n int64
for _, c := range s {
if c < '0' || c > '9' {
return 0, errInvalid
}
n = n*10 + int64(c-'0')
}
*dest = n
return n, nil
}
type invalidErr struct{}
func (invalidErr) Error() string { return "invalid" }
var errInvalid = invalidErr{}
+212
View File
@@ -0,0 +1,212 @@
package server
import (
"net/http"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleListProjectTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
list, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
TicketID string `json:"ticketId"`
Duration int64 `json:"durationNs"`
Note string `json:"note"`
Source string `json:"source"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: body.TicketID,
ProjectID: id,
Duration: time.Duration(body.Duration),
Note: body.Note,
Source: body.Source,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "create", u.ID, e)
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
Start *time.Time `json:"start"`
End *time.Time `json:"end"`
Duration int64 `json:"durationNs"`
Note string `json:"note"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var found *ticket.TimeEntry
for i := range entries {
if entries[i].ID == eid {
found = &entries[i]
break
}
}
if found == nil {
writeError(w, http.StatusNotFound, "not found")
return
}
if body.Start != nil || body.End != nil {
if body.Start != nil {
found.Start = body.Start.UTC()
}
if body.End != nil {
found.End = body.End.UTC()
}
if !found.End.After(found.Start) {
writeError(w, http.StatusBadRequest, "end must be after start")
return
}
found.Duration = found.End.Sub(found.Start)
} else if body.Duration > 0 {
found.Duration = time.Duration(body.Duration)
found.End = found.Start.Add(found.Duration)
}
found.Note = body.Note
if err := s.Times.Update(*found); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", found.ID, id, "update", u.ID, found)
writeJSON(w, http.StatusOK, found)
}
func (s *Server) handleDeleteTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
entries, _ := s.Times.ListByProject(id)
for _, e := range entries {
if e.ID == eid {
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "delete", u.ID, e)
break
}
}
if err := s.Times.Delete(eid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
t, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if t == nil {
writeJSON(w, http.StatusOK, map[string]any{"timer": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{"timer": t})
}
func (s *Server) handleStartTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, ok, code := s.requireProjectRole(body.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
t := &ticket.ActiveTimer{
TicketID: body.TicketID,
ProjectID: body.ProjectID,
StartedAt: time.Now().UTC(),
}
if err := s.Times.SaveActiveTimer(t); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = u
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleStopTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
active, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if active == nil {
writeError(w, http.StatusBadRequest, "no active timer")
return
}
if _, ok, code := s.requireProjectRole(active.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
dur := time.Since(active.StartedAt)
if dur < time.Second {
dur = time.Second
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: active.TicketID,
ProjectID: active.ProjectID,
Start: active.StartedAt,
End: time.Now().UTC(),
Duration: dur,
Source: ticket.SourceTimer,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.Times.SaveActiveTimer(nil)
_, _ = s.DB.AppendRevision("time_entry", e.ID, active.ProjectID, "create", u.ID, e)
writeJSON(w, http.StatusOK, e)
}
+130
View File
@@ -0,0 +1,130 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"task/internal/sqlite"
)
func TestUpdateTimeStartEnd(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
timeBody, _ := json.Marshal(map[string]any{
"ticketId": tid,
"durationNs": int64(30 * time.Minute),
"source": "manual",
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/time", bytes.NewReader(timeBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create time %d %s", rec.Code, rec.Body.String())
}
var entry map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
eid, _ := entry["id"].(string)
if eid == "" {
t.Fatal("no entry id")
}
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)
end := start.Add(90 * time.Minute)
patchBody, _ := json.Marshal(map[string]any{
"start": start,
"end": end,
})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch start/end %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
dur, _ := entry["duration"].(float64)
if time.Duration(dur) != 90*time.Minute {
t.Fatalf("duration want 90m got %v", time.Duration(dur))
}
// duration-only still works
patchBody, _ = json.Marshal(map[string]any{"durationNs": int64(45 * time.Minute)})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch duration %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
dur, _ = entry["duration"].(float64)
if time.Duration(dur) != 45*time.Minute {
t.Fatalf("duration want 45m got %v", time.Duration(dur))
}
// invalid range
badEnd := start.Add(-time.Minute)
patchBody, _ = json.Marshal(map[string]any{"start": start, "end": badEnd})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid range want 400 got %d %s", rec.Code, rec.Body.String())
}
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"net/http"
"task/internal/sqlite"
)
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
list, err := s.DB.ListProjectTokens(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r)
if !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
var body struct {
Name string `json:"name"`
Scope string `json:"scope"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.Scope == "" {
body.Scope = sqlite.TokenScopeWrite
}
t, err := s.DB.CreateProjectToken(id, body.Name, body.Scope, u.ID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
tid := r.PathValue("tid")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
writeError(w, code, "forbidden")
return
}
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
if err := s.DB.RevokeProjectToken(id, tid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
me, _ := s.currentUser(r)
var body struct {
IsAdmin *bool `json:"isAdmin"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if body.IsAdmin == nil {
writeError(w, http.StatusBadRequest, "isAdmin required")
return
}
if id == me.ID && !*body.IsAdmin {
writeError(w, http.StatusBadRequest, "cannot remove your own admin flag")
return
}
if err := s.DB.SetUserAdmin(id, *body.IsAdmin); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
u, err := s.DB.GetUserByID(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
u.PasswordHash = ""
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
me, _ := s.currentUser(r)
if id == me.ID {
writeError(w, http.StatusBadRequest, "cannot delete yourself")
return
}
if err := s.DB.DeleteUser(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleDashboard returns a lightweight overview for the front page.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
if _, tok := s.currentTokenAuth(r); tok {
writeError(w, http.StatusForbidden, "session required")
return
}
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type openTicket struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
ProjectID string `json:"projectId"`
Project string `json:"projectName"`
}
type projSum struct {
ID string `json:"id"`
Name string `json:"name"`
OpenCount int `json:"openCount"`
DurationNs int64 `json:"durationNs"`
}
open := make([]openTicket, 0)
sums := make([]projSum, 0, len(projects))
for _, p := range projects {
tickets, err := s.Tickets.List(p.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
openCount := 0
for _, t := range tickets {
if string(t.Status) != "done" {
openCount++
if len(open) < 20 {
open = append(open, openTicket{
ID: t.ID, Title: t.Title, Status: string(t.Status),
ProjectID: p.ID, Project: p.Name,
})
}
}
}
entries, _ := s.Times.ListByProject(p.ID)
var dur int64
for _, e := range entries {
dur += int64(e.Duration)
}
sums = append(sums, projSum{ID: p.ID, Name: p.Name, OpenCount: openCount, DurationNs: dur})
}
writeJSON(w, http.StatusOK, map[string]any{
"projects": sums,
"openTickets": open,
})
}
+104
View File
@@ -0,0 +1,104 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestProjectTokenBearerAuth(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
admin, err := db.CreateUser("admin", "pass", true)
if err != nil {
t.Fatal(err)
}
srv := New(db)
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid := proj["id"].(string)
tokBody, _ := json.Marshal(map[string]string{"name": "tui", "scope": "write"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tokens", bytes.NewReader(tokBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create token %d %s", rec.Code, rec.Body.String())
}
var tok map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
raw := tok["token"].(string)
if raw == "" {
t.Fatal("expected raw token")
}
ticketBody, _ := json.Marshal(map[string]string{"title": "Via token"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+raw)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("bearer create ticket %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer list %d", rec.Code)
}
// delete other user
_, _ = db.CreateUser("bob", "x", false)
users, _ := db.ListUsers()
var bobID string
for _, u := range users {
if u.Username == "bob" {
bobID = u.ID
}
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/users/"+bobID, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete user %d %s", rec.Code, rec.Body.String())
}
patch, _ := json.Marshal(map[string]bool{"isAdmin": false})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/users/"+admin.ID, bytes.NewReader(patch))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatal("should not demote self")
}
}
+131
View File
@@ -0,0 +1,131 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
// Attachment is metadata for a file attached to a ticket.
type Attachment struct {
ID string `json:"id"`
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
Filename string `json:"filename"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
// AttachmentStore persists attachment metadata.
type AttachmentStore struct {
db *DB
}
// NewAttachmentStore returns an attachment metadata store.
func NewAttachmentStore(db *DB) *AttachmentStore {
return &AttachmentStore{db: db}
}
const attachmentCols = `id, ticket_id, project_id, filename, content_type, size_bytes, created_by, created_at`
func scanAttachment(scanner interface{ Scan(dest ...any) error }) (Attachment, error) {
var a Attachment
var created string
err := scanner.Scan(
&a.ID, &a.TicketID, &a.ProjectID, &a.Filename, &a.ContentType,
&a.SizeBytes, &a.CreatedBy, &created,
)
if err != nil {
return Attachment{}, err
}
a.CreatedAt = parseTime(created)
return a, nil
}
// ListByTicket returns attachments for a ticket, newest first.
func (s *AttachmentStore) ListByTicket(ticketID string) ([]Attachment, error) {
rows, err := s.db.SQL.Query(
`SELECT `+attachmentCols+` FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at DESC`,
ticketID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
if out == nil {
out = []Attachment{}
}
return out, rows.Err()
}
// Get returns one attachment by id.
func (s *AttachmentStore) Get(id string) (Attachment, error) {
row := s.db.SQL.QueryRow(`SELECT `+attachmentCols+` FROM ticket_attachments WHERE id = ?`, id)
a, err := scanAttachment(row)
if err == sql.ErrNoRows {
return Attachment{}, fmt.Errorf("attachment not found")
}
return a, err
}
// Create inserts attachment metadata.
func (s *AttachmentStore) Create(a Attachment) (Attachment, error) {
if a.ID == "" {
a.ID = uuid.NewString()
}
if a.Filename == "" {
return Attachment{}, fmt.Errorf("filename required")
}
if a.ContentType == "" {
a.ContentType = "application/octet-stream"
}
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now().UTC()
}
_, err := s.db.SQL.Exec(
`INSERT INTO ticket_attachments (`+attachmentCols+`) VALUES (?,?,?,?,?,?,?,?)`,
a.ID, a.TicketID, a.ProjectID, a.Filename, a.ContentType, a.SizeBytes, a.CreatedBy, formatTime(a.CreatedAt),
)
if err != nil {
return Attachment{}, err
}
return a, nil
}
// UpdateSize updates size after a replace/overwrite.
func (s *AttachmentStore) UpdateSize(id string, sizeBytes int64) error {
res, err := s.db.SQL.Exec(`UPDATE ticket_attachments SET size_bytes = ? WHERE id = ?`, sizeBytes, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("attachment not found")
}
return nil
}
// Delete removes attachment metadata.
func (s *AttachmentStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM ticket_attachments WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("attachment not found")
}
return nil
}
+280
View File
@@ -0,0 +1,280 @@
package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
)
// Role constants for project membership.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleViewer = "viewer"
)
// User is a local account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
IsAdmin bool `json:"isAdmin"`
CreatedAt time.Time `json:"createdAt"`
}
// Member is a project membership.
type Member struct {
ProjectID string `json:"projectId"`
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
Role string `json:"role"`
}
// HashPassword hashes a password with argon2id.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return fmt.Sprintf("%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// CheckPassword verifies a password against a stored hash.
func CheckPassword(password, encoded string) bool {
parts := strings.SplitN(encoded, "$", 2)
if len(parts) != 2 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
if len(got) != len(want) {
return false
}
var v byte
for i := range got {
v |= got[i] ^ want[i]
}
return v == 0
}
// CreateUser inserts a user.
func (db *DB) CreateUser(username, password string, isAdmin bool) (User, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return User{}, fmt.Errorf("username and password required")
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
u := User{
ID: uuid.NewString(),
Username: username,
PasswordHash: hash,
IsAdmin: isAdmin,
CreatedAt: time.Now().UTC(),
}
_, err = db.SQL.Exec(`INSERT INTO users(id, username, password_hash, is_admin, created_at) VALUES(?,?,?,?,?)`,
u.ID, u.Username, u.PasswordHash, boolInt(isAdmin), formatTime(u.CreatedAt))
if err != nil {
return User{}, fmt.Errorf("create user: %w", err)
}
return u, nil
}
// CountUsers returns the number of users.
func (db *DB) CountUsers() (int, error) {
var n int
err := db.SQL.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// GetUserByUsername looks up a user.
func (db *DB) GetUserByUsername(username string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE username = ? COLLATE NOCASE`, username))
}
// GetUserByID looks up a user by id.
func (db *DB) GetUserByID(id string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE id = ?`, id))
}
// ListUsers returns all users.
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.SQL.Query(`SELECT id, username, password_hash, is_admin, created_at FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := db.scanUser(rows)
if err != nil {
return nil, err
}
u.PasswordHash = ""
out = append(out, u)
}
if out == nil {
out = []User{}
}
return out, rows.Err()
}
func (db *DB) scanUser(scanner interface{ Scan(dest ...any) error }) (User, error) {
var u User
var admin int
var created string
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &created)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("user not found")
}
if err != nil {
return User{}, err
}
u.IsAdmin = admin != 0
u.CreatedAt = parseTime(created)
return u, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// CreateSession creates a session and returns the raw token (only once).
func (db *DB) CreateSession(userID string, ttl time.Duration) (rawToken string, expiresAt time.Time, err error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", time.Time{}, err
}
rawToken = base64.RawURLEncoding.EncodeToString(buf)
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
expiresAt = time.Now().UTC().Add(ttl)
_, err = db.SQL.Exec(`INSERT INTO sessions(id, user_id, token_hash, expires_at, created_at) VALUES(?,?,?,?,?)`,
uuid.NewString(), userID, tokenHash, formatTime(expiresAt), formatTime(time.Now().UTC()))
return rawToken, expiresAt, err
}
// UserFromSession resolves a session token to a user.
func (db *DB) UserFromSession(rawToken string) (User, error) {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
var userID, expires string
err := db.SQL.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token_hash = ?`, tokenHash).
Scan(&userID, &expires)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("invalid session")
}
if err != nil {
return User{}, err
}
if parseTime(expires).Before(time.Now().UTC()) {
_, _ = db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return User{}, fmt.Errorf("session expired")
}
return db.GetUserByID(userID)
}
// DeleteSession removes a session by raw token.
func (db *DB) DeleteSession(rawToken string) error {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
_, err := db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return err
}
// SetMember sets or updates a project membership.
func (db *DB) SetMember(projectID, userID, role string) error {
switch role {
case RoleOwner, RoleAdmin, RoleMember, RoleViewer:
default:
return fmt.Errorf("invalid role %q", role)
}
_, err := db.SQL.Exec(`
INSERT INTO project_members(project_id, user_id, role) VALUES(?,?,?)
ON CONFLICT(project_id, user_id) DO UPDATE SET role=excluded.role`,
projectID, userID, role)
return err
}
// RemoveMember deletes a membership.
func (db *DB) RemoveMember(projectID, userID string) error {
_, err := db.SQL.Exec(`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
return err
}
// ListMembers lists members of a project.
func (db *DB) ListMembers(projectID string) ([]Member, error) {
rows, err := db.SQL.Query(`
SELECT m.project_id, m.user_id, u.username, m.role
FROM project_members m
JOIN users u ON u.id = m.user_id
WHERE m.project_id = ?
ORDER BY u.username COLLATE NOCASE`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Member
for rows.Next() {
var m Member
if err := rows.Scan(&m.ProjectID, &m.UserID, &m.Username, &m.Role); err != nil {
return nil, err
}
out = append(out, m)
}
if out == nil {
out = []Member{}
}
return out, rows.Err()
}
// MemberRole returns the role for a user on a project, or empty if none.
func (db *DB) MemberRole(projectID, userID string) (string, error) {
var role string
err := db.SQL.QueryRow(`SELECT role FROM project_members WHERE project_id = ? AND user_id = ?`,
projectID, userID).Scan(&role)
if err == sql.ErrNoRows {
return "", nil
}
return role, err
}
// RoleAtLeast reports whether role meets the required minimum.
func RoleAtLeast(role, required string) bool {
rank := map[string]int{
RoleViewer: 1,
RoleMember: 2,
RoleAdmin: 3,
RoleOwner: 4,
}
return rank[role] >= rank[required]
}
+131
View File
@@ -0,0 +1,131 @@
package sqlite
import (
"database/sql"
"fmt"
)
// backfillTicketKeys assigns project codes and sequential ticket keys for rows
// that predate migration 005. Safe to run repeatedly (no-ops when already set).
func (db *DB) backfillTicketKeys() error {
if err := db.backfillProjectCodes(); err != nil {
return err
}
return db.backfillTicketNumbers()
}
func (db *DB) backfillProjectCodes() error {
rows, err := db.SQL.Query(`SELECT id, name, code FROM projects`)
if err != nil {
return fmt.Errorf("backfill codes: list: %w", err)
}
defer rows.Close()
type row struct {
id, name, code string
}
var all []row
used := map[string]bool{}
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.name, &r.code); err != nil {
return err
}
all = append(all, r)
if r.code != "" {
used[r.code] = true
}
}
if err := rows.Err(); err != nil {
return err
}
for _, r := range all {
if r.code != "" {
continue
}
code := nextUniqueCode(r.name, used)
used[code] = true
if _, err := db.SQL.Exec(`UPDATE projects SET code = ? WHERE id = ?`, code, r.id); err != nil {
return fmt.Errorf("backfill code %s: %w", r.id, err)
}
}
return nil
}
func (db *DB) backfillTicketNumbers() error {
type proj struct {
id, code string
seq int
}
var projects []proj
{
projRows, err := db.SQL.Query(`SELECT id, code, ticket_seq FROM projects`)
if err != nil {
return fmt.Errorf("backfill tickets: projects: %w", err)
}
for projRows.Next() {
var p proj
if err := projRows.Scan(&p.id, &p.code, &p.seq); err != nil {
projRows.Close()
return err
}
projects = append(projects, p)
}
err = projRows.Err()
projRows.Close()
if err != nil {
return err
}
}
for _, p := range projects {
type trow struct {
id, key string
number sql.NullInt64
}
var tickets []trow
{
tRows, err := db.SQL.Query(`
SELECT id, number, key FROM tickets
WHERE project_id = ?
ORDER BY created_at ASC, id ASC`, p.id)
if err != nil {
return err
}
for tRows.Next() {
var tr trow
if err := tRows.Scan(&tr.id, &tr.number, &tr.key); err != nil {
tRows.Close()
return err
}
tickets = append(tickets, tr)
}
err = tRows.Err()
tRows.Close()
if err != nil {
return err
}
}
seq := p.seq
for _, tr := range tickets {
if tr.number.Valid && tr.key != "" {
if int(tr.number.Int64) > seq {
seq = int(tr.number.Int64)
}
continue
}
seq++
newKey := fmt.Sprintf("%s-%d", p.code, seq)
if _, err := db.SQL.Exec(`UPDATE tickets SET number = ?, key = ? WHERE id = ?`, seq, newKey, tr.id); err != nil {
return fmt.Errorf("backfill ticket %s: %w", tr.id, err)
}
}
if seq != p.seq {
if _, err := db.SQL.Exec(`UPDATE projects SET ticket_seq = ? WHERE id = ?`, seq, p.id); err != nil {
return err
}
}
}
return nil
}
+118
View File
@@ -0,0 +1,118 @@
package sqlite
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Connection is a remote backend credential (instance or project scope).
type Connection struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Scope string `json:"scope"` // instance | project
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
Token string `json:"-"` // only populated when decrypted for sync
}
// CreateConnection stores an encrypted token.
func (db *DB) CreateConnection(name, kind, baseURL, scope, token, createdBy string) (Connection, error) {
name = strings.TrimSpace(name)
kind = strings.TrimSpace(kind)
baseURL = strings.TrimSpace(baseURL)
scope = strings.TrimSpace(scope)
if name == "" || kind == "" || baseURL == "" || token == "" {
return Connection{}, fmt.Errorf("name, kind, baseUrl and token required")
}
if scope == "" {
scope = "instance"
}
enc, err := db.EncryptToken(token)
if err != nil {
return Connection{}, err
}
c := Connection{
ID: uuid.NewString(),
Name: name,
Kind: kind,
BaseURL: baseURL,
Scope: scope,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
}
_, err = db.SQL.Exec(`
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
VALUES(?,?,?,?,?,?,?,?)`,
c.ID, c.Name, c.Kind, c.BaseURL, c.Scope, enc, c.CreatedBy, formatTime(c.CreatedAt))
if err != nil {
return Connection{}, err
}
return c, nil
}
// ListConnections returns connections without tokens.
func (db *DB) ListConnections() ([]Connection, error) {
rows, err := db.SQL.Query(`
SELECT id, name, kind, base_url, scope, created_by, created_at
FROM connections ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Connection
for rows.Next() {
var c Connection
var created string
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &c.CreatedBy, &created); err != nil {
return nil, err
}
c.CreatedAt = parseTime(created)
out = append(out, c)
}
if out == nil {
out = []Connection{}
}
return out, rows.Err()
}
// GetConnection loads a connection and decrypts its token.
func (db *DB) GetConnection(id string) (Connection, error) {
var c Connection
var created string
var blob []byte
err := db.SQL.QueryRow(`
SELECT id, name, kind, base_url, scope, encrypted_token, created_by, created_at
FROM connections WHERE id = ?`, id).
Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &blob, &c.CreatedBy, &created)
if err == sql.ErrNoRows {
return Connection{}, fmt.Errorf("connection %s: not found", id)
}
if err != nil {
return Connection{}, err
}
c.CreatedAt = parseTime(created)
c.Token, err = db.DecryptToken(blob)
if err != nil {
return Connection{}, err
}
return c, nil
}
// DeleteConnection removes a connection.
func (db *DB) DeleteConnection(id string) error {
res, err := db.SQL.Exec(`DELETE FROM connections WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("connection %s: not found", id)
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package sqlite
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
)
func normalizeSecret(secret []byte) []byte {
if len(secret) == 0 {
// Deterministic fallback for local/dev; production should set TASK_MASTER_SECRET.
sum := sha256.Sum256([]byte("task-master-dev-secret"))
return sum[:]
}
sum := sha256.Sum256(secret)
return sum[:]
}
// EncryptToken seals plaintext with AES-GCM.
func (db *DB) EncryptToken(plain string) ([]byte, error) {
block, err := aes.NewCipher(db.Secret)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
}
// DecryptToken opens an AES-GCM blob.
func (db *DB) DecryptToken(blob []byte) (string, error) {
block, err := aes.NewCipher(db.Secret)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(blob) < gcm.NonceSize() {
return "", fmt.Errorf("token ciphertext too short")
}
nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
+133
View File
@@ -0,0 +1,133 @@
package sqlite
import (
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// DB wraps a SQLite connection and domain helpers.
type DB struct {
SQL *sql.DB
Secret []byte // AES-256 key material for connection tokens (derived/padded)
}
// Open opens (or creates) the database at path and runs migrations.
func Open(path string, secret []byte) (*DB, error) {
if path == "" {
return nil, fmt.Errorf("sqlite: path required")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("sqlite: create dir: %w", err)
}
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, fmt.Errorf("sqlite: open: %w", err)
}
sqlDB.SetMaxOpenConns(1) // SQLite writer safety
if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("sqlite: ping: %w", err)
}
db := &DB{SQL: sqlDB, Secret: normalizeSecret(secret)}
if err := db.migrate(); err != nil {
_ = sqlDB.Close()
return nil, err
}
if err := db.backfillTicketKeys(); err != nil {
_ = sqlDB.Close()
return nil, err
}
return db, nil
}
// Close closes the underlying connection.
func (db *DB) Close() error {
if db == nil || db.SQL == nil {
return nil
}
return db.SQL.Close()
}
func (db *DB) migrate() error {
if _, err := db.SQL.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("sqlite: schema_migrations: %w", err)
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("sqlite: list migrations: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
var exists string
err := db.SQL.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&exists)
if err == nil {
continue
}
if err != sql.ErrNoRows {
return fmt.Errorf("sqlite: check migration %s: %w", name, err)
}
body, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("sqlite: read migration %s: %w", name, err)
}
tx, err := db.SQL.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(body)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("sqlite: apply %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(name, applied_at) VALUES(?, ?)`,
name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("sqlite: record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
+117
View File
@@ -0,0 +1,117 @@
package sqlite
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// ProjectGroup is a folder for organizing projects in the UI tree.
type ProjectGroup struct {
ID string `json:"id"`
Name string `json:"name"`
SortOrder int `json:"sortOrder"`
}
// ListProjectGroups returns all groups sorted by sort_order then name.
func (db *DB) ListProjectGroups() ([]ProjectGroup, error) {
rows, err := db.SQL.Query(`
SELECT id, name, sort_order FROM project_groups
ORDER BY sort_order ASC, name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list project groups: %w", err)
}
defer rows.Close()
var out []ProjectGroup
for rows.Next() {
var g ProjectGroup
if err := rows.Scan(&g.ID, &g.Name, &g.SortOrder); err != nil {
return nil, err
}
out = append(out, g)
}
if out == nil {
out = []ProjectGroup{}
}
return out, rows.Err()
}
// CreateProjectGroup inserts a new group.
func (db *DB) CreateProjectGroup(name string) (ProjectGroup, error) {
name = strings.TrimSpace(name)
if name == "" {
return ProjectGroup{}, fmt.Errorf("name is required")
}
id := uuid.NewString()
var maxOrd sql.NullInt64
_ = db.SQL.QueryRow(`SELECT MAX(sort_order) FROM project_groups`).Scan(&maxOrd)
ord := 0
if maxOrd.Valid {
ord = int(maxOrd.Int64) + 1
}
now := formatTime(time.Now().UTC())
_, err := db.SQL.Exec(`INSERT INTO project_groups(id, name, sort_order, created_at) VALUES(?, ?, ?, ?)`,
id, name, ord, now)
if err != nil {
return ProjectGroup{}, fmt.Errorf("create project group: %w", err)
}
return ProjectGroup{ID: id, Name: name, SortOrder: ord}, nil
}
// RenameProjectGroup updates the group name.
func (db *DB) RenameProjectGroup(id, name string) error {
name = strings.TrimSpace(name)
if id == "" || name == "" {
return fmt.Errorf("id and name are required")
}
res, err := db.SQL.Exec(`UPDATE project_groups SET name = ? WHERE id = ?`, name, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("group %s: not found", id)
}
return nil
}
// DeleteProjectGroup removes a group; projects in it become ungrouped.
func (db *DB) DeleteProjectGroup(id string) error {
if id == "" {
return fmt.Errorf("id is required")
}
tx, err := db.SQL.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`UPDATE projects SET group_id = NULL WHERE group_id = ?`, id); err != nil {
return fmt.Errorf("ungroup projects: %w", err)
}
res, err := tx.Exec(`DELETE FROM project_groups WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete group %s: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("group %s: not found", id)
}
return tx.Commit()
}
// GetProjectGroup returns one group.
func (db *DB) GetProjectGroup(id string) (ProjectGroup, error) {
var g ProjectGroup
err := db.SQL.QueryRow(`SELECT id, name, sort_order FROM project_groups WHERE id = ?`, id).
Scan(&g.ID, &g.Name, &g.SortOrder)
if err == sql.ErrNoRows {
return ProjectGroup{}, fmt.Errorf("group %s: not found", id)
}
if err != nil {
return ProjectGroup{}, err
}
return g, nil
}
+115
View File
@@ -0,0 +1,115 @@
-- schema version tracked in app_meta
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_members_user ON project_members(user_id);
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
external_url TEXT NOT NULL DEFAULT '',
synced_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id);
CREATE INDEX IF NOT EXISTS idx_tickets_external ON tickets(external_id);
CREATE TABLE IF NOT EXISTS time_entries (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL,
project_id TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
duration_ns INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_time_ticket ON time_entries(ticket_id);
CREATE INDEX IF NOT EXISTS idx_time_project ON time_entries(project_id);
CREATE TABLE IF NOT EXISTS active_timer (
id INTEGER PRIMARY KEY CHECK (id = 1),
ticket_id TEXT NOT NULL,
project_id TEXT NOT NULL,
started_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
base_url TEXT NOT NULL,
scope TEXT NOT NULL,
encrypted_token BLOB NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_backends (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'none',
base_url TEXT NOT NULL DEFAULT '',
project_key TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
connection_id TEXT REFERENCES connections(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
rev INTEGER NOT NULL,
at TEXT NOT NULL,
actor_user_id TEXT,
op TEXT NOT NULL,
snapshot_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revisions_entity ON revisions(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_revisions_project ON revisions(project_id);
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS project_tokens (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_project_tokens_project ON project_tokens(project_id);
CREATE INDEX IF NOT EXISTS idx_project_tokens_hash ON project_tokens(token_hash);
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS project_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_project_groups_sort ON project_groups(sort_order, name COLLATE NOCASE);
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_projects_group ON projects(group_id);
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS ticket_attachments (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON ticket_attachments(ticket_id);
@@ -0,0 +1,9 @@
ALTER TABLE projects ADD COLUMN code TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN ticket_seq INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tickets ADD COLUMN number INTEGER;
ALTER TABLE tickets ADD COLUMN key TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code) WHERE code != '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_key ON tickets(key) WHERE key != '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_project_number ON tickets(project_id, number) WHERE number IS NOT NULL;
+49
View File
@@ -0,0 +1,49 @@
package sqlite
import (
"fmt"
"strings"
"unicode"
)
// DeriveProjectCode builds a 3-letter uppercase code from a project name.
// Non-letters are skipped; short results are padded with X.
func DeriveProjectCode(name string) string {
var b strings.Builder
for _, r := range strings.ToUpper(name) {
if unicode.IsLetter(r) && r < unicode.MaxASCII {
b.WriteRune(r)
if b.Len() == 3 {
break
}
}
}
code := b.String()
for len(code) < 3 {
code += "X"
}
return code
}
// nextUniqueCode returns base or a nearby 3-char variant until unused.
// used maps existing codes (uppercase) → true.
func nextUniqueCode(base string, used map[string]bool) string {
base = DeriveProjectCode(base)
if !used[base] {
return base
}
prefix := base[:2]
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" {
cand := prefix + string(c)
if !used[cand] {
return cand
}
}
for i := 0; i < 1000; i++ {
cand := fmt.Sprintf("%c%02d", base[0], i)
if !used[cand] {
return cand
}
}
return fmt.Sprintf("X%02d", len(used)%100)
}
+32
View File
@@ -0,0 +1,32 @@
package sqlite
import "testing"
func TestDeriveProjectCode(t *testing.T) {
cases := []struct {
name, want string
}{
{"Alpha", "ALP"},
{"task-master", "TAS"},
{"AB", "ABX"},
{"12", "XXX"},
{"Büro Projekt", "BRO"}, // non-ASCII letters skipped
{"my project", "MYP"},
}
for _, tc := range cases {
if got := DeriveProjectCode(tc.name); got != tc.want {
t.Errorf("DeriveProjectCode(%q)=%q want %q", tc.name, got, tc.want)
}
}
}
func TestNextUniqueCode(t *testing.T) {
used := map[string]bool{"ALP": true, "ALA": true}
got := nextUniqueCode("Alpha", used)
if got == "ALP" || got == "ALA" {
t.Fatalf("expected unique code, got %q", got)
}
if len(got) != 3 {
t.Fatalf("len=%d code=%q", len(got), got)
}
}
+393
View File
@@ -0,0 +1,393 @@
package sqlite
import (
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"task/internal/project"
)
// ProjectStore implements project.Store on SQLite.
type ProjectStore struct {
db *DB
}
// NewProjectStore returns a project store.
func NewProjectStore(db *DB) *ProjectStore {
return &ProjectStore{db: db}
}
func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
var p project.Project
var groupID sql.NullString
if err := scanner.Scan(&p.ID, &p.Name, &p.Code, &groupID); err != nil {
return project.Project{}, err
}
if groupID.Valid {
p.GroupID = groupID.String
}
return p, nil
}
const projectCols = `id, name, code, group_id`
// List returns all projects sorted by name.
func (s *ProjectStore) List() ([]project.Project, error) {
rows, err := s.db.SQL.Query(`SELECT ` + projectCols + ` FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
defer rows.Close()
var out []project.Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
if out == nil {
out = []project.Project{}
}
return out, rows.Err()
}
func (s *ProjectStore) usedCodes() (map[string]bool, error) {
rows, err := s.db.SQL.Query(`SELECT code FROM projects WHERE code != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
used := map[string]bool{}
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
used[c] = true
}
return used, rows.Err()
}
// Ensure creates the project if missing.
func (s *ProjectStore) Ensure(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("project id is required")
}
used, err := s.usedCodes()
if err != nil {
return err
}
code := nextUniqueCode(id, used)
now := formatTime(time.Now().UTC())
_, err = s.db.SQL.Exec(`
INSERT INTO projects(id, name, code, created_at, updated_at) VALUES(?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`, id, id, code, now, now)
if err != nil {
return fmt.Errorf("ensure project %s: %w", id, err)
}
var existing string
_ = s.db.SQL.QueryRow(`SELECT code FROM projects WHERE id = ?`, id).Scan(&existing)
if existing == "" {
return s.db.backfillProjectCodes()
}
return nil
}
// Create creates a project. Empty id is replaced with a new UUID.
func (s *ProjectStore) Create(id, name string) (project.Project, error) {
id = strings.TrimSpace(id)
name = strings.TrimSpace(name)
if name == "" {
return project.Project{}, fmt.Errorf("project name is required")
}
if id == "" {
id = uuid.NewString()
}
used, err := s.usedCodes()
if err != nil {
return project.Project{}, err
}
code := nextUniqueCode(name, used)
now := formatTime(time.Now().UTC())
_, err = s.db.SQL.Exec(`INSERT INTO projects(id, name, code, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, ?, NULL)`,
id, name, code, now, now)
if err != nil {
return project.Project{}, fmt.Errorf("create project: %w", err)
}
return project.Project{ID: id, Name: name, Code: code}, nil
}
// SetGroup assigns a project to a group. Empty groupID clears the assignment.
func (s *ProjectStore) SetGroup(id, groupID string) error {
id = strings.TrimSpace(id)
groupID = strings.TrimSpace(groupID)
if id == "" {
return fmt.Errorf("project id is required")
}
var arg any
if groupID == "" {
arg = nil
} else {
arg = groupID
}
res, err := s.db.SQL.Exec(`UPDATE projects SET group_id = ?, updated_at = ? WHERE id = ?`,
arg, formatTime(time.Now().UTC()), id)
if err != nil {
return fmt.Errorf("set project group: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Rename updates the display name.
func (s *ProjectStore) Rename(id, name string) error {
name = strings.TrimSpace(name)
if id == "" || name == "" {
return fmt.Errorf("id and name are required")
}
res, err := s.db.SQL.Exec(`UPDATE projects SET name = ?, updated_at = ? WHERE id = ?`,
name, formatTime(time.Now().UTC()), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Delete removes a project and cascaded rows.
func (s *ProjectStore) Delete(id string) error {
if id == "" {
return fmt.Errorf("project id is required")
}
res, err := s.db.SQL.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete project %s: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Get returns one project.
func (s *ProjectStore) Get(id string) (project.Project, error) {
p, err := scanProject(s.db.SQL.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id))
if err == sql.ErrNoRows {
return project.Project{}, fmt.Errorf("project %s: not found", id)
}
if err != nil {
return project.Project{}, err
}
return p, nil
}
// LoadMeta maps sync_backends (+ connection token) into project.Meta.
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
if id == "" {
return project.Meta{}, fmt.Errorf("project id is required")
}
var kind, baseURL, projectKey, query string
var connID sql.NullString
err := s.db.SQL.QueryRow(`
SELECT kind, base_url, project_key, query, connection_id
FROM sync_backends WHERE project_id = ?`, id).
Scan(&kind, &baseURL, &projectKey, &query, &connID)
if err == sql.ErrNoRows {
return project.Meta{}, nil
}
if err != nil {
return project.Meta{}, fmt.Errorf("load meta %s: %w", id, err)
}
if kind != "youtrack" {
return project.Meta{}, nil
}
yt := &project.YouTrack{
BaseURL: baseURL,
ProjectKey: projectKey,
Query: query,
}
if connID.Valid && connID.String != "" {
token, err := s.db.connectionToken(connID.String)
if err != nil {
return project.Meta{}, err
}
yt.Token = token
}
return project.Meta{YouTrack: yt}, nil
}
// SaveMeta writes YouTrack settings into sync_backends (creates inline connection if token set).
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
if id == "" {
return fmt.Errorf("project id is required")
}
if err := s.Ensure(id); err != nil {
return err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() && strings.TrimSpace(meta.YouTrack.BaseURL) == "" {
_, err := s.db.SQL.Exec(`DELETE FROM sync_backends WHERE project_id = ?`, id)
return err
}
yt := meta.YouTrack
var connID sql.NullString
if strings.TrimSpace(yt.Token) != "" {
cid, err := s.db.ensureProjectConnection(id, yt.BaseURL, yt.Token)
if err != nil {
return err
}
connID = sql.NullString{String: cid, Valid: true}
}
_, err := s.db.SQL.Exec(`
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
VALUES(?, 'youtrack', ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
kind=excluded.kind,
base_url=excluded.base_url,
project_key=excluded.project_key,
query=excluded.query,
connection_id=COALESCE(excluded.connection_id, sync_backends.connection_id)`,
id, yt.BaseURL, yt.ProjectKey, yt.Query, connID)
return err
}
// SyncBackend is the persisted per-project sync config (API-facing).
type SyncBackend struct {
ProjectID string `json:"projectId"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
ProjectKey string `json:"projectKey"`
Query string `json:"query,omitempty"`
ConnectionID string `json:"connectionId,omitempty"`
}
// GetSyncBackend returns sync config for a project.
func (s *ProjectStore) GetSyncBackend(projectID string) (SyncBackend, error) {
var b SyncBackend
var connID sql.NullString
err := s.db.SQL.QueryRow(`
SELECT project_id, kind, base_url, project_key, query, connection_id
FROM sync_backends WHERE project_id = ?`, projectID).
Scan(&b.ProjectID, &b.Kind, &b.BaseURL, &b.ProjectKey, &b.Query, &connID)
if err == sql.ErrNoRows {
return SyncBackend{ProjectID: projectID, Kind: "none"}, nil
}
if err != nil {
return SyncBackend{}, err
}
if connID.Valid {
b.ConnectionID = connID.String
}
return b, nil
}
// SetSyncBackend upserts sync config.
func (s *ProjectStore) SetSyncBackend(b SyncBackend) error {
if b.ProjectID == "" {
return fmt.Errorf("projectId required")
}
if b.Kind == "" {
b.Kind = "none"
}
var connID sql.NullString
if b.ConnectionID != "" {
connID = sql.NullString{String: b.ConnectionID, Valid: true}
}
_, err := s.db.SQL.Exec(`
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
VALUES(?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
kind=excluded.kind,
base_url=excluded.base_url,
project_key=excluded.project_key,
query=excluded.query,
connection_id=excluded.connection_id`,
b.ProjectID, b.Kind, b.BaseURL, b.ProjectKey, b.Query, connID)
return err
}
// ListIDsForUser returns project IDs the user can access (all if admin).
func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Project, error) {
if isAdmin {
return s.List()
}
rows, err := s.db.SQL.Query(`
SELECT p.id, p.name, p.code, p.group_id FROM projects p
INNER JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ?
ORDER BY p.name COLLATE NOCASE`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []project.Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
if out == nil {
out = []project.Project{}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, rows.Err()
}
func (db *DB) ensureProjectConnection(projectID, baseURL, token string) (string, error) {
// Reuse existing sync_backends connection if present.
var existing sql.NullString
_ = db.SQL.QueryRow(`SELECT connection_id FROM sync_backends WHERE project_id = ?`, projectID).Scan(&existing)
if existing.Valid && existing.String != "" {
enc, err := db.EncryptToken(token)
if err != nil {
return "", err
}
_, err = db.SQL.Exec(`UPDATE connections SET encrypted_token = ?, base_url = ? WHERE id = ?`,
enc, baseURL, existing.String)
if err != nil {
return "", err
}
return existing.String, nil
}
id := "conn_" + projectID
enc, err := db.EncryptToken(token)
if err != nil {
return "", err
}
_, err = db.SQL.Exec(`
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
VALUES(?, ?, 'youtrack', ?, 'project', ?, NULL, ?)
ON CONFLICT(id) DO UPDATE SET encrypted_token=excluded.encrypted_token, base_url=excluded.base_url`,
id, "project:"+projectID, baseURL, enc, formatTime(time.Now().UTC()))
if err != nil {
return "", err
}
return id, nil
}
func (db *DB) connectionToken(id string) (string, error) {
var blob []byte
err := db.SQL.QueryRow(`SELECT encrypted_token FROM connections WHERE id = ?`, id).Scan(&blob)
if err == sql.ErrNoRows {
return "", fmt.Errorf("connection %s not found", id)
}
if err != nil {
return "", err
}
return db.DecryptToken(blob)
}
+120
View File
@@ -0,0 +1,120 @@
package sqlite
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Revision is a versioned snapshot of an entity.
type Revision struct {
ID int64 `json:"id"`
EntityType string `json:"entityType"`
EntityID string `json:"entityId"`
ProjectID string `json:"projectId"`
Rev int `json:"rev"`
At time.Time `json:"at"`
ActorUserID string `json:"actorUserId,omitempty"`
Op string `json:"op"`
Snapshot json.RawMessage `json:"snapshot"`
}
// AppendRevision stores a new revision snapshot.
func (db *DB) AppendRevision(entityType, entityID, projectID, op, actorUserID string, snapshot any) (Revision, error) {
data, err := json.Marshal(snapshot)
if err != nil {
return Revision{}, err
}
var maxRev sql.NullInt64
_ = db.SQL.QueryRow(`SELECT MAX(rev) FROM revisions WHERE entity_type = ? AND entity_id = ?`,
entityType, entityID).Scan(&maxRev)
rev := 1
if maxRev.Valid {
rev = int(maxRev.Int64) + 1
}
at := time.Now().UTC()
var actor any
if actorUserID != "" {
actor = actorUserID
}
res, err := db.SQL.Exec(`
INSERT INTO revisions(entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json)
VALUES(?,?,?,?,?,?,?,?)`,
entityType, entityID, projectID, rev, formatTime(at), actor, op, string(data))
if err != nil {
return Revision{}, err
}
id, _ := res.LastInsertId()
return Revision{
ID: id,
EntityType: entityType,
EntityID: entityID,
ProjectID: projectID,
Rev: rev,
At: at,
ActorUserID: actorUserID,
Op: op,
Snapshot: data,
}, nil
}
// ListRevisions returns revisions for an entity, newest first.
func (db *DB) ListRevisions(entityType, entityID string) ([]Revision, error) {
rows, err := db.SQL.Query(`
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
FROM revisions WHERE entity_type = ? AND entity_id = ?
ORDER BY rev DESC`, entityType, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRevisions(rows)
}
// GetRevision returns one revision by id.
func (db *DB) GetRevision(id int64) (Revision, error) {
row := db.SQL.QueryRow(`
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
FROM revisions WHERE id = ?`, id)
var r Revision
var at string
var actor sql.NullString
var snap string
err := row.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap)
if err == sql.ErrNoRows {
return Revision{}, fmt.Errorf("revision %d: not found", id)
}
if err != nil {
return Revision{}, err
}
r.At = parseTime(at)
if actor.Valid {
r.ActorUserID = actor.String
}
r.Snapshot = json.RawMessage(snap)
return r, nil
}
func scanRevisions(rows *sql.Rows) ([]Revision, error) {
var out []Revision
for rows.Next() {
var r Revision
var at string
var actor sql.NullString
var snap string
if err := rows.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap); err != nil {
return nil, err
}
r.At = parseTime(at)
if actor.Valid {
r.ActorUserID = actor.String
}
r.Snapshot = json.RawMessage(snap)
out = append(out, r)
}
if out == nil {
out = []Revision{}
}
return out, rows.Err()
}
+98
View File
@@ -0,0 +1,98 @@
package sqlite
import (
"path/filepath"
"testing"
"time"
"task/internal/ticket"
)
func TestStoresRoundTrip(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "test.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
if err := ps.Ensure("inbox"); err != nil {
t.Fatal(err)
}
ts := NewTicketStore(db)
created, err := ts.Create(ticket.Ticket{Title: "Hello", ProjectID: "inbox", Status: ticket.StatusTodo})
if err != nil {
t.Fatal(err)
}
got, err := ts.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "Hello" {
t.Fatalf("title=%q", got.Title)
}
got.Title = "Updated"
if err := ts.Update(got); err != nil {
t.Fatal(err)
}
if err := ts.MarkSynced(got.ID); err != nil {
t.Fatal(err)
}
synced, _ := ts.Get(got.ID)
if synced.SyncedAt.IsZero() || !synced.SyncedAt.Equal(synced.UpdatedAt) {
t.Fatalf("syncedAt=%v updatedAt=%v", synced.SyncedAt, synced.UpdatedAt)
}
timeStore := NewTimeStore(db)
_, err = timeStore.Create(ticket.TimeEntry{
TicketID: created.ID,
ProjectID: "inbox",
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
sum, err := timeStore.SumByTicket(created.ID)
if err != nil || sum != time.Hour {
t.Fatalf("sum=%v err=%v", sum, err)
}
u, err := db.CreateUser("admin", "secret", true)
if err != nil {
t.Fatal(err)
}
token, _, err := db.CreateSession(u.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
me, err := db.UserFromSession(token)
if err != nil || me.Username != "admin" {
t.Fatalf("session user=%v err=%v", me, err)
}
if err := db.SetMember("inbox", u.ID, RoleOwner); err != nil {
t.Fatal(err)
}
role, _ := db.MemberRole("inbox", u.ID)
if role != RoleOwner {
t.Fatalf("role=%s", role)
}
_, err = db.AppendRevision("ticket", created.ID, "inbox", "update", u.ID, synced)
if err != nil {
t.Fatal(err)
}
revs, err := db.ListRevisions("ticket", created.ID)
if err != nil || len(revs) != 1 {
t.Fatalf("revs=%v err=%v", revs, err)
}
conn, err := db.CreateConnection("YT", "youtrack", "https://yt.example", "instance", "tok-123", u.ID)
if err != nil {
t.Fatal(err)
}
loaded, err := db.GetConnection(conn.ID)
if err != nil || loaded.Token != "tok-123" {
t.Fatalf("token=%q err=%v", loaded.Token, err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package sqlite
import (
"path/filepath"
"testing"
"task/internal/ticket"
)
func TestTicketKeysSequential(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "keys.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
p, err := ps.Create("", "Task Master")
if err != nil {
t.Fatal(err)
}
if p.Code != "TAS" {
t.Fatalf("code=%q want TAS", p.Code)
}
ts := NewTicketStore(db)
a, err := ts.Create(ticket.Ticket{Title: "One", ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
b, err := ts.Create(ticket.Ticket{Title: "Two", ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
if a.Key != "TAS-1" || a.Number != 1 {
t.Fatalf("first=%+v", a)
}
if b.Key != "TAS-2" || b.Number != 2 {
t.Fatalf("second=%+v", b)
}
got, err := ts.Get(a.ID)
if err != nil || got.Key != "TAS-1" {
t.Fatalf("get=%+v err=%v", got, err)
}
// Update must preserve key.
got.Title = "One*"
if err := ts.Update(got); err != nil {
t.Fatal(err)
}
got, _ = ts.Get(a.ID)
if got.Key != "TAS-1" || got.Title != "One*" {
t.Fatalf("after update=%+v", got)
}
}
func TestTicketKeyBackfill(t *testing.T) {
path := filepath.Join(t.TempDir(), "backfill.db")
db, err := Open(path, []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
// Insert legacy-shaped rows as if pre-migration (columns exist after Open).
_, err = db.SQL.Exec(`INSERT INTO projects(id, name, code, ticket_seq, created_at, updated_at)
VALUES('p1', 'Inbox', '', 0, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')`)
if err != nil {
t.Fatal(err)
}
_, err = db.SQL.Exec(`INSERT INTO tickets(id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key)
VALUES('t1', 'Old', '', 'todo', 'p1', '2020-01-02T00:00:00Z', '2020-01-02T00:00:00Z', '', '', '', '', NULL, '')`)
if err != nil {
t.Fatal(err)
}
if err := db.backfillTicketKeys(); err != nil {
t.Fatal(err)
}
var code string
var seq int
if err := db.SQL.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id='p1'`).Scan(&code, &seq); err != nil {
t.Fatal(err)
}
if code != "INB" || seq != 1 {
t.Fatalf("project code=%q seq=%d", code, seq)
}
var key string
var num int
if err := db.SQL.QueryRow(`SELECT key, number FROM tickets WHERE id='t1'`).Scan(&key, &num); err != nil {
t.Fatal(err)
}
if key != "INB-1" || num != 1 {
t.Fatalf("ticket key=%q num=%d", key, num)
}
_ = db.Close()
}
func TestMoveReassignsKey(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "move.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
a, err := ps.Create("", "Alpha")
if err != nil {
t.Fatal(err)
}
b, err := ps.Create("", "Beta")
if err != nil {
t.Fatal(err)
}
ts := NewTicketStore(db)
tk, err := ts.Create(ticket.Ticket{Title: "Move me", ProjectID: a.ID})
if err != nil {
t.Fatal(err)
}
moved, err := ts.Move(tk.ID, b.ID)
if err != nil {
t.Fatal(err)
}
if moved.ProjectID != b.ID || moved.Key != b.Code+"-1" {
t.Fatalf("moved=%+v betaCode=%s", moved, b.Code)
}
}
+276
View File
@@ -0,0 +1,276 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"task/internal/ticket"
)
// TicketStore implements ticket.Store on SQLite.
type TicketStore struct {
db *DB
}
// NewTicketStore returns a ticket store.
func NewTicketStore(db *DB) *TicketStore {
return &TicketStore{db: db}
}
func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, error) {
var t ticket.Ticket
var created, updated, synced string
var status string
var number sql.NullInt64
err := scanner.Scan(
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
&number, &t.Key,
)
if err != nil {
return ticket.Ticket{}, err
}
t.Status = ticket.Status(status)
t.CreatedAt = parseTime(created)
t.UpdatedAt = parseTime(updated)
t.SyncedAt = parseTime(synced)
if number.Valid {
t.Number = int(number.Int64)
}
return t, nil
}
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key`
// List returns tickets for a project.
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
rows, err := s.db.SQL.Query(`SELECT `+ticketCols+` FROM tickets WHERE project_id = ? ORDER BY updated_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ticket.Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
if out == nil {
out = []ticket.Ticket{}
}
return out, rows.Err()
}
// Get finds a ticket by ID.
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
row := s.db.SQL.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
t, err := scanTicket(row)
if err == sql.ErrNoRows {
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
if err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Create inserts a ticket and assigns the next project key atomically.
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
if t.ProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
if t.Title == "" {
return ticket.Ticket{}, fmt.Errorf("title is required")
}
if t.ID == "" {
t.ID = uuid.NewString()
}
if t.Status == "" {
t.Status = ticket.StatusTodo
}
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
tx, err := s.db.SQL.Begin()
if err != nil {
return ticket.Ticket{}, err
}
defer func() { _ = tx.Rollback() }()
if t.Number == 0 || t.Key == "" {
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, t.ProjectID)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: bump seq: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s not found", t.ProjectID)
}
var code string
var seq int
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, t.ProjectID).Scan(&code, &seq); err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: read seq: %w", err)
}
if code == "" {
return ticket.Ticket{}, fmt.Errorf("create ticket: project %s has no code", t.ProjectID)
}
t.Number = seq
t.Key = fmt.Sprintf("%s-%d", code, seq)
}
var number any
if t.Number > 0 {
number = t.Number
}
_, err = tx.Exec(`
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Title, t.Description, string(t.Status), t.ProjectID,
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
number, t.Key,
)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
}
if err := tx.Commit(); err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Update replaces a ticket and bumps UpdatedAt. Number and Key are preserved from DB.
func (s *TicketStore) Update(t ticket.Ticket) error {
if t.ID == "" || t.ProjectID == "" {
return fmt.Errorf("id and projectId are required")
}
var created string
var number sql.NullInt64
var key string
err := s.db.SQL.QueryRow(`SELECT created_at, number, key FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).
Scan(&created, &number, &key)
if err == sql.ErrNoRows {
return fmt.Errorf("ticket %s: not found", t.ID)
}
if err != nil {
return err
}
t.CreatedAt = parseTime(created)
t.UpdatedAt = time.Now().UTC()
if number.Valid {
t.Number = int(number.Int64)
}
t.Key = key
res, err := s.db.SQL.Exec(`
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
source=?, external_id=?, external_url=?, synced_at=?, number=?, key=?
WHERE id=?`,
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), nullInt(t.Number), t.Key, t.ID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", t.ID)
}
return nil
}
func nullInt(n int) any {
if n == 0 {
return nil
}
return n
}
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
func (s *TicketStore) MarkSynced(id string) error {
if id == "" {
return fmt.Errorf("ticket id is required")
}
res, err := s.db.SQL.Exec(`UPDATE tickets SET synced_at = updated_at WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", id)
}
return nil
}
// Move relocates a ticket to another project and assigns a new key there.
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
if id == "" {
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
}
if toProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
tx, err := s.db.SQL.Begin()
if err != nil {
return ticket.Ticket{}, err
}
defer func() { _ = tx.Rollback() }()
row := tx.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
t, err := scanTicket(row)
if err == sql.ErrNoRows {
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
if err != nil {
return ticket.Ticket{}, err
}
res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, toProjectID)
if err != nil {
return ticket.Ticket{}, err
}
n, _ := res.RowsAffected()
if n == 0 {
return ticket.Ticket{}, fmt.Errorf("project %s: not found", toProjectID)
}
var code string
var seq int
if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, toProjectID).Scan(&code, &seq); err != nil {
return ticket.Ticket{}, err
}
t.ProjectID = toProjectID
t.Number = seq
t.Key = fmt.Sprintf("%s-%d", code, seq)
t.UpdatedAt = time.Now().UTC()
_, err = tx.Exec(`UPDATE tickets SET project_id=?, updated_at=?, number=?, key=? WHERE id=?`,
toProjectID, formatTime(t.UpdatedAt), t.Number, t.Key, id)
if err != nil {
return ticket.Ticket{}, err
}
if err := tx.Commit(); err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Delete removes a ticket.
func (s *TicketStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM tickets WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", id)
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"task/internal/ticket"
)
// TimeStore implements ticket.TimeStore on SQLite.
type TimeStore struct {
db *DB
}
// NewTimeStore returns a time store.
func NewTimeStore(db *DB) *TimeStore {
return &TimeStore{db: db}
}
func scanEntry(scanner interface{ Scan(dest ...any) error }) (ticket.TimeEntry, error) {
var e ticket.TimeEntry
var start, end string
var dur int64
err := scanner.Scan(&e.ID, &e.TicketID, &e.ProjectID, &start, &end, &dur, &e.Source, &e.Note, &e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
e.Start = parseTime(start)
e.End = parseTime(end)
e.Duration = time.Duration(dur)
return e, nil
}
const entryCols = `id, ticket_id, project_id, start, end, duration_ns, source, note, external_id`
// List returns all time entries.
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT ` + entryCols + ` FROM time_entries ORDER BY start DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByTicket returns entries for one ticket.
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE ticket_id = ? ORDER BY start DESC`, ticketID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByProject returns entries for a project.
func (s *TimeStore) ListByProject(projectID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE project_id = ? ORDER BY start DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
func collectEntries(rows *sql.Rows) ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
if out == nil {
out = []ticket.TimeEntry{}
}
return out, rows.Err()
}
// Create adds a time entry.
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
if e.TicketID == "" {
return ticket.TimeEntry{}, fmt.Errorf("ticketId is required")
}
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
e.Duration = e.End.Sub(e.Start)
}
if e.Duration <= 0 {
return ticket.TimeEntry{}, fmt.Errorf("duration must be positive")
}
if e.ID == "" {
e.ID = uuid.NewString()
}
if e.Source == "" {
e.Source = ticket.SourceManual
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
if e.End.IsZero() {
e.End = e.Start.Add(e.Duration)
}
_, err := s.db.SQL.Exec(`INSERT INTO time_entries(`+entryCols+`) VALUES(?,?,?,?,?,?,?,?,?)`,
e.ID, e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End),
int64(e.Duration), e.Source, e.Note, e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
return e, nil
}
// Update replaces a time entry.
func (s *TimeStore) Update(e ticket.TimeEntry) error {
if e.ID == "" {
return fmt.Errorf("time entry id is required")
}
if e.Duration <= 0 {
return fmt.Errorf("duration must be positive")
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
e.End = e.Start.Add(e.Duration)
res, err := s.db.SQL.Exec(`
UPDATE time_entries SET ticket_id=?, project_id=?, start=?, end=?, duration_ns=?, source=?, note=?, external_id=?
WHERE id=?`,
e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End), int64(e.Duration), e.Source, e.Note, e.ExternalID, e.ID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", e.ID)
}
return nil
}
// Delete removes a time entry.
func (s *TimeStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", id)
}
return nil
}
// RetargetTicket updates project_id for all entries of a ticket.
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
if ticketID == "" || projectID == "" {
return fmt.Errorf("ticketId and projectId are required")
}
_, err := s.db.SQL.Exec(`UPDATE time_entries SET project_id = ? WHERE ticket_id = ?`, projectID, ticketID)
return err
}
// DeleteByProject removes all entries for a project.
func (s *TimeStore) DeleteByProject(projectID string) error {
if projectID == "" {
return fmt.Errorf("projectId is required")
}
_, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE project_id = ?`, projectID)
return err
}
// SumByTicket returns total duration.
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
var sum sql.NullInt64
err := s.db.SQL.QueryRow(`SELECT SUM(duration_ns) FROM time_entries WHERE ticket_id = ?`, ticketID).Scan(&sum)
if err != nil {
return 0, err
}
if !sum.Valid {
return 0, nil
}
return time.Duration(sum.Int64), nil
}
// LoadActiveTimer returns the running timer or nil.
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
var t ticket.ActiveTimer
var started string
err := s.db.SQL.QueryRow(`SELECT ticket_id, project_id, started_at FROM active_timer WHERE id = 1`).
Scan(&t.TicketID, &t.ProjectID, &started)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
t.StartedAt = parseTime(started)
if t.TicketID == "" {
return nil, nil
}
return &t, nil
}
// SaveActiveTimer writes or clears the active timer.
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
if t == nil {
_, err := s.db.SQL.Exec(`DELETE FROM active_timer WHERE id = 1`)
return err
}
_, err := s.db.SQL.Exec(`
INSERT INTO active_timer(id, ticket_id, project_id, started_at) VALUES(1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ticket_id=excluded.ticket_id, project_id=excluded.project_id, started_at=excluded.started_at`,
t.TicketID, t.ProjectID, formatTime(t.StartedAt))
return err
}
+226
View File
@@ -0,0 +1,226 @@
package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
TokenScopeRead = "read"
TokenScopeWrite = "write" // includes read
)
// ProjectToken is a project-scoped API credential (secret only on create).
type ProjectToken struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Scope string `json:"scope"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
Token string `json:"token,omitempty"` // raw secret, only set on create
}
// CreateProjectToken issues a new token; raw value is returned once in Token.
func (db *DB) CreateProjectToken(projectID, name, scope, createdBy string) (ProjectToken, error) {
name = strings.TrimSpace(name)
scope = strings.TrimSpace(scope)
if projectID == "" || name == "" {
return ProjectToken{}, fmt.Errorf("projectId and name required")
}
if scope != TokenScopeRead && scope != TokenScopeWrite {
return ProjectToken{}, fmt.Errorf("scope must be read or write")
}
raw, err := randomToken()
if err != nil {
return ProjectToken{}, err
}
hash := hashToken(raw)
t := ProjectToken{
ID: uuid.NewString(),
ProjectID: projectID,
Name: name,
Scope: scope,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
Token: raw,
}
_, err = db.SQL.Exec(`
INSERT INTO project_tokens(id, project_id, name, token_hash, scope, created_by, created_at)
VALUES(?,?,?,?,?,?,?)`,
t.ID, t.ProjectID, t.Name, hash, t.Scope, nullStr(createdBy), formatTime(t.CreatedAt))
if err != nil {
return ProjectToken{}, err
}
return t, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return "tm_" + base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return base64.RawStdEncoding.EncodeToString(sum[:])
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
// ListProjectTokens returns non-secret token metadata for a project.
func (db *DB) ListProjectTokens(projectID string) ([]ProjectToken, error) {
rows, err := db.SQL.Query(`
SELECT id, project_id, name, scope, created_by, created_at, revoked_at
FROM project_tokens WHERE project_id = ?
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProjectToken
for rows.Next() {
t, err := scanProjectToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
if out == nil {
out = []ProjectToken{}
}
return out, rows.Err()
}
// RevokeProjectToken soft-revokes a token.
func (db *DB) RevokeProjectToken(projectID, tokenID string) error {
res, err := db.SQL.Exec(`
UPDATE project_tokens SET revoked_at = ? WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
formatTime(time.Now().UTC()), tokenID, projectID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("token not found or already revoked")
}
return nil
}
// TokenAuth is the resolved bearer identity.
type TokenAuth struct {
User User
ProjectID string
Scope string
TokenID string
}
// ResolveBearerToken validates a raw API token.
func (db *DB) ResolveBearerToken(raw string) (TokenAuth, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return TokenAuth{}, fmt.Errorf("empty token")
}
hash := hashToken(raw)
var (
tokenID, projectID, scope string
createdBy sql.NullString
revoked sql.NullString
)
err := db.SQL.QueryRow(`
SELECT id, project_id, scope, created_by, revoked_at
FROM project_tokens WHERE token_hash = ?`, hash).
Scan(&tokenID, &projectID, &scope, &createdBy, &revoked)
if err == sql.ErrNoRows {
return TokenAuth{}, fmt.Errorf("invalid token")
}
if err != nil {
return TokenAuth{}, err
}
if revoked.Valid && revoked.String != "" {
return TokenAuth{}, fmt.Errorf("token revoked")
}
var u User
if createdBy.Valid && createdBy.String != "" {
u, err = db.GetUserByID(createdBy.String)
if err != nil {
// creator deleted — synthetic limited principal
u = User{ID: createdBy.String, Username: "token", IsAdmin: false}
}
} else {
u = User{ID: "token:" + tokenID, Username: "token", IsAdmin: false}
}
u.PasswordHash = ""
return TokenAuth{User: u, ProjectID: projectID, Scope: scope, TokenID: tokenID}, nil
}
// TokenAllows reports whether scope permits the operation (write implies read).
func TokenAllows(scope, need string) bool {
if need == TokenScopeRead {
return scope == TokenScopeRead || scope == TokenScopeWrite
}
return scope == TokenScopeWrite
}
func scanProjectToken(scanner interface{ Scan(dest ...any) error }) (ProjectToken, error) {
var t ProjectToken
var createdBy sql.NullString
var created, revoked sql.NullString
err := scanner.Scan(&t.ID, &t.ProjectID, &t.Name, &t.Scope, &createdBy, &created, &revoked)
if err != nil {
return ProjectToken{}, err
}
if createdBy.Valid {
t.CreatedBy = createdBy.String
}
t.CreatedAt = parseTime(created.String)
if revoked.Valid && revoked.String != "" {
tm := parseTime(revoked.String)
t.RevokedAt = &tm
}
return t, nil
}
// DeleteUser removes a user and their sessions.
func (db *DB) DeleteUser(id string) error {
if id == "" {
return fmt.Errorf("user id required")
}
res, err := db.SQL.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}
// SetUserAdmin updates the is_admin flag.
func (db *DB) SetUserAdmin(id string, isAdmin bool) error {
res, err := db.SQL.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package syncbackend
import (
"context"
"fmt"
"task/internal/project"
"task/internal/ticket"
"task/internal/ytsync"
)
// RemoteProject is a project discovered on a remote backend.
type RemoteProject struct {
Key string `json:"key"`
Name string `json:"name"`
ID string `json:"id,omitempty"`
}
// Connection credentials for a remote instance or project.
type Connection struct {
Kind string
BaseURL string
Token string
Scope string
}
// ProjectConfig is the per-local-project sync settings.
type ProjectConfig struct {
Kind string
BaseURL string
Token string
ProjectKey string
Query string
}
// Result reuses ytsync.Result for pull/push summaries.
type Result = ytsync.Result
// Backend syncs tickets with an external system.
type Backend interface {
Kind() string
ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error)
Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
}
// Registry maps kind → Backend.
type Registry struct {
backends map[string]Backend
}
// NewRegistry returns a registry with built-in backends.
func NewRegistry() *Registry {
r := &Registry{backends: map[string]Backend{}}
r.Register(&NoneBackend{})
r.Register(&YouTrackBackend{})
return r
}
// Register adds a backend.
func (r *Registry) Register(b Backend) {
r.backends[b.Kind()] = b
}
// Get returns a backend by kind.
func (r *Registry) Get(kind string) (Backend, error) {
if kind == "" {
kind = "none"
}
b, ok := r.backends[kind]
if !ok {
return nil, fmt.Errorf("unknown sync backend %q", kind)
}
return b, nil
}
// NoneBackend disables sync.
type NoneBackend struct{}
func (NoneBackend) Kind() string { return "none" }
func (NoneBackend) ListProjects(context.Context, Connection) ([]RemoteProject, error) {
return nil, fmt.Errorf("no remote projects for kind none")
}
func (NoneBackend) Pull(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
return Result{}, fmt.Errorf("sync disabled")
}
func (NoneBackend) Push(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
return Result{}, fmt.Errorf("sync disabled")
}
// YouTrackBackend wraps existing ytsync.
type YouTrackBackend struct{}
func (YouTrackBackend) Kind() string { return "youtrack" }
func (YouTrackBackend) ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error) {
return ListYouTrackProjects(ctx, conn.BaseURL, conn.Token)
}
func (YouTrackBackend) Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
yt := toYouTrack(cfg)
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Pull(ctx, projectID, yt)
}
func (YouTrackBackend) Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
yt := toYouTrack(cfg)
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Push(ctx, projectID, yt)
}
func toYouTrack(cfg ProjectConfig) project.YouTrack {
return project.YouTrack{
BaseURL: cfg.BaseURL,
Token: cfg.Token,
ProjectKey: cfg.ProjectKey,
Query: cfg.Query,
}
}
+29
View File
@@ -0,0 +1,29 @@
package syncbackend
import (
"context"
"task/internal/youtrack"
)
// ListYouTrackProjects lists projects via a master/instance token.
func ListYouTrackProjects(ctx context.Context, baseURL, token string) ([]RemoteProject, error) {
client := &youtrack.Client{BaseURL: baseURL, Token: token}
projects, err := client.ListProjects(ctx)
if err != nil {
return nil, err
}
out := make([]RemoteProject, 0, len(projects))
for _, p := range projects {
name := p.Name
if name == "" {
name = p.ShortName
}
out = append(out, RemoteProject{
Key: p.ShortName,
Name: name,
ID: p.ID,
})
}
return out, nil
}
+122
View File
@@ -1,5 +1,10 @@
package ticket
import (
"strings"
"unicode"
)
// BoardList is a project-scoped view/filter over tickets by status.
type BoardList string
@@ -29,6 +34,15 @@ func (b BoardList) Label() string {
}
}
// Shortcut returns the board-menu hotkey (first letter of the label).
func (b BoardList) Shortcut() rune {
label := b.Label()
if label == "" {
return 0
}
return unicode.ToLower([]rune(label)[0])
}
// Index returns the position in AllBoardLists.
func (b BoardList) Index() int {
for i, bl := range AllBoardLists {
@@ -50,6 +64,114 @@ func ParseBoardList(s string) BoardList {
}
}
// BoardFilter is a multi-select set of board lists (OR semantics).
type BoardFilter []BoardList
// ParseBoardLists parses a comma-separated list id string (legacy single values work).
func ParseBoardLists(s string) BoardFilter {
s = strings.TrimSpace(s)
if s == "" {
return BoardFilter{BoardAll}
}
parts := strings.Split(s, ",")
var out BoardFilter
seen := map[BoardList]bool{}
for _, p := range parts {
bl := ParseBoardList(strings.TrimSpace(p))
if seen[bl] {
continue
}
seen[bl] = true
out = append(out, bl)
}
return NormalizeFilter(out)
}
// FormatBoardLists serializes a filter for config persistence.
func FormatBoardLists(f BoardFilter) string {
f = NormalizeFilter(f)
parts := make([]string, len(f))
for i, bl := range f {
parts[i] = string(bl)
}
return strings.Join(parts, ",")
}
// NormalizeFilter ensures a non-empty filter; All alone if empty or only All.
// If All is combined with others, All is dropped.
func NormalizeFilter(f BoardFilter) BoardFilter {
if len(f) == 0 {
return BoardFilter{BoardAll}
}
hasAll := false
var others BoardFilter
seen := map[BoardList]bool{}
for _, bl := range f {
if bl == BoardAll {
hasAll = true
continue
}
if seen[bl] {
continue
}
seen[bl] = true
others = append(others, bl)
}
if len(others) > 0 {
return others
}
if hasAll {
return BoardFilter{BoardAll}
}
return BoardFilter{BoardAll}
}
// Contains reports whether bl is in the filter.
func (f BoardFilter) Contains(bl BoardList) bool {
for _, x := range f {
if x == bl {
return true
}
}
return false
}
// Label returns a short header label for the active filter.
func (f BoardFilter) Label() string {
f = NormalizeFilter(f)
if len(f) == 1 {
return f[0].Label()
}
parts := make([]string, len(f))
for i, bl := range f {
parts[i] = bl.Label()
}
return strings.Join(parts, "+")
}
// Matches reports whether a ticket status is visible under this filter.
func (f BoardFilter) Matches(status Status) bool {
f = NormalizeFilter(f)
if f.Contains(BoardAll) {
return true
}
for _, bl := range f {
if bl.Matches(status) {
return true
}
}
return false
}
// DefaultStatus is used when creating a ticket under this filter.
func (f BoardFilter) DefaultStatus() Status {
f = NormalizeFilter(f)
if len(f) == 1 {
return f[0].DefaultStatus()
}
return StatusTodo
}
// Matches reports whether a ticket belongs on this board list.
func (b BoardList) Matches(status Status) bool {
switch b {
+35
View File
@@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) {
t.Fatal("all default")
}
}
func TestBoardFilterMultiSelect(t *testing.T) {
f := ticket.ParseBoardLists("backlog,done")
if !f.Matches(ticket.StatusTodo) || !f.Matches(ticket.StatusDone) {
t.Fatalf("expected backlog+done match, got %v", f)
}
if f.Matches(ticket.StatusInProgress) {
t.Fatal("should not match in progress")
}
if got := ticket.FormatBoardLists(f); got != "backlog,done" {
t.Fatalf("format = %q", got)
}
}
func TestBoardFilterAllDominatesNormalize(t *testing.T) {
f := ticket.NormalizeFilter(ticket.BoardFilter{ticket.BoardAll, ticket.BoardBacklog})
if len(f) != 1 || f[0] != ticket.BoardBacklog {
t.Fatalf("got %v", f)
}
}
func TestBoardListShortcut(t *testing.T) {
if ticket.BoardAll.Shortcut() != 'a' {
t.Fatal("all")
}
if ticket.BoardBacklog.Shortcut() != 'b' {
t.Fatal("backlog")
}
if ticket.BoardInProgress.Shortcut() != 'i' {
t.Fatal("in progress")
}
if ticket.BoardDone.Shortcut() != 'd' {
t.Fatal("done")
}
}
+63
View File
@@ -0,0 +1,63 @@
package ticket
import (
"fmt"
"regexp"
"strings"
)
// taskCheckboxRe matches GFM/YouTrack-style task list items:
//
// - [ ] unchecked
// - [x] checked
// * [X] also accepted
var taskCheckboxRe = regexp.MustCompile(`^(\s*)([-*+])\s+\[([ xX])\](\s*)(.*)$`)
// Checkbox is one task-list item inside a Markdown description.
type Checkbox struct {
Line int // 0-based line index in the source
Checked bool
Text string // label after the checkbox marker
}
// ListCheckboxes returns all task-list items in markdown source order.
func ListCheckboxes(markdown string) []Checkbox {
if markdown == "" {
return nil
}
lines := strings.Split(markdown, "\n")
var out []Checkbox
for i, line := range lines {
m := taskCheckboxRe.FindStringSubmatch(line)
if m == nil {
continue
}
out = append(out, Checkbox{
Line: i,
Checked: m[3] == "x" || m[3] == "X",
Text: m[5],
})
}
return out
}
// ToggleCheckbox flips the checked state of the checkbox at index
// (0-based among ListCheckboxes results) and returns the updated markdown.
func ToggleCheckbox(markdown string, index int) (string, error) {
boxes := ListCheckboxes(markdown)
if index < 0 || index >= len(boxes) {
return markdown, fmt.Errorf("checkbox %d: not found", index)
}
lines := strings.Split(markdown, "\n")
lineIdx := boxes[index].Line
m := taskCheckboxRe.FindStringSubmatch(lines[lineIdx])
if m == nil {
return markdown, fmt.Errorf("checkbox %d: line changed", index)
}
mark := " "
if !(m[3] == "x" || m[3] == "X") {
mark = "x"
}
lines[lineIdx] = m[1] + m[2] + " [" + mark + "]" + m[4] + m[5]
return strings.Join(lines, "\n"), nil
}
+65
View File
@@ -0,0 +1,65 @@
package ticket
import (
"testing"
)
func TestListCheckboxes(t *testing.T) {
src := `# Steps
- [ ] one
- [x] two
* [X] three
- [ ] nested
Not a box: [ ] plain
`
got := ListCheckboxes(src)
if len(got) != 4 {
t.Fatalf("len=%d want 4: %+v", len(got), got)
}
if got[0].Text != "one" || got[0].Checked || got[0].Line != 2 {
t.Fatalf("first: %+v", got[0])
}
if !got[1].Checked || got[1].Text != "two" {
t.Fatalf("second: %+v", got[1])
}
if !got[2].Checked || got[2].Text != "three" {
t.Fatalf("third: %+v", got[2])
}
if got[3].Text != "nested" || got[3].Checked || got[3].Line != 5 {
t.Fatalf("nested: %+v", got[3])
}
}
func TestToggleCheckbox(t *testing.T) {
src := "- [ ] a\n- [x] b\n"
out, err := ToggleCheckbox(src, 0)
if err != nil {
t.Fatal(err)
}
if out != "- [x] a\n- [x] b\n" {
t.Fatalf("toggle 0: %q", out)
}
out, err = ToggleCheckbox(out, 1)
if err != nil {
t.Fatal(err)
}
if out != "- [x] a\n- [ ] b\n" {
t.Fatalf("toggle 1: %q", out)
}
if _, err := ToggleCheckbox(src, 9); err == nil {
t.Fatal("expected error for bad index")
}
}
func TestToggleCheckboxPreservesIndentAndBullet(t *testing.T) {
src := " * [X] deep"
out, err := ToggleCheckbox(src, 0)
if err != nil {
t.Fatal(err)
}
if out != " * [ ] deep" {
t.Fatalf("got %q", out)
}
}
+15
View File
@@ -0,0 +1,15 @@
package ticket
import "testing"
func TestDisplayID(t *testing.T) {
if got := (Ticket{Key: "TAS-1", ExternalID: "uuid"}).DisplayID(); got != "TAS-1" {
t.Fatalf("got %q", got)
}
if got := (Ticket{ExternalID: "YT-9"}).DisplayID(); got != "YT-9" {
t.Fatalf("got %q", got)
}
if got := (Ticket{}).DisplayID(); got != "" {
t.Fatalf("got %q", got)
}
}
+59
View File
@@ -0,0 +1,59 @@
package ticket
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
var (
reHourMin = regexp.MustCompile(`(?i)^(\d+)\s*h(?:ours?)?\s*(\d+)\s*m(?:ins?)?$`)
reHourOnly = regexp.MustCompile(`(?i)^(\d+(?:\.\d+)?)\s*h(?:ours?)?$`)
reMinOnly = regexp.MustCompile(`(?i)^(\d+)\s*m(?:ins?)?$`)
reSecOnly = regexp.MustCompile(`(?i)^(\d+)\s*s(?:ecs?)?$`)
reColon = regexp.MustCompile(`^(\d+):(\d{1,2})$`)
)
// ParseDurationFlexible parses user durations like 1h30m, 90m, 1.5h, 1:30.
func ParseDurationFlexible(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("duration required")
}
// Prefer Go's parser when it works (e.g. 1h30m, 90m).
if d, err := time.ParseDuration(strings.ToLower(s)); err == nil && d > 0 {
return d, nil
}
if m := reHourMin.FindStringSubmatch(s); m != nil {
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
}
if m := reHourOnly.FindStringSubmatch(s); m != nil {
h, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, fmt.Errorf("invalid duration %q", s)
}
return time.Duration(h * float64(time.Hour)), nil
}
if m := reMinOnly.FindStringSubmatch(s); m != nil {
min, _ := strconv.Atoi(m[1])
return time.Duration(min) * time.Minute, nil
}
if m := reSecOnly.FindStringSubmatch(s); m != nil {
sec, _ := strconv.Atoi(m[1])
return time.Duration(sec) * time.Second, nil
}
if m := reColon.FindStringSubmatch(s); m != nil {
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
if min >= 60 {
return 0, fmt.Errorf("invalid minutes in %q", s)
}
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
}
return 0, fmt.Errorf("invalid duration %q (try 1h30m, 90m, 1.5h)", s)
}
+58
View File
@@ -0,0 +1,58 @@
package ticket_test
import (
"testing"
"time"
"task/internal/ticket"
)
func TestParseDurationFlexible(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"1h30m", 90 * time.Minute},
{"90m", 90 * time.Minute},
{"1.5h", 90 * time.Minute},
{"2h", 2 * time.Hour},
{"45m", 45 * time.Minute},
{"30s", 30 * time.Second},
{"1:30", 90 * time.Minute},
{" 1h ", time.Hour},
}
for _, tc := range cases {
got, err := ticket.ParseDurationFlexible(tc.in)
if err != nil {
t.Fatalf("%q: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("%q: got %v want %v", tc.in, got, tc.want)
}
}
}
func TestParseDurationFlexibleRejects(t *testing.T) {
for _, in := range []string{"", "abc", "1:99", "-5m"} {
if _, err := ticket.ParseDurationFlexible(in); err == nil {
t.Fatalf("%q: expected error", in)
}
}
}
func TestFormatDurationHuman(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{2*time.Hour + 15*time.Minute, "2h15m"},
{3 * time.Hour, "3h"},
{45 * time.Minute, "45m"},
{30 * time.Second, "30s"},
}
for _, tc := range cases {
if got := ticket.FormatDurationHuman(tc.d); got != tc.want {
t.Fatalf("%v: got %q want %q", tc.d, got, tc.want)
}
}
}
+105
View File
@@ -134,6 +134,8 @@ func (s *JSONStore) Update(t Ticket) error {
for i := range tickets {
if tickets[i].ID == t.ID {
t.CreatedAt = tickets[i].CreatedAt
// Always bump UpdatedAt on local edits so NeedsPush sees dirty tickets.
// Use MarkSynced after a successful remote sync to clear the dirty flag.
t.UpdatedAt = time.Now().UTC()
tickets[i] = t
found = true
@@ -146,6 +148,109 @@ func (s *JSONStore) Update(t Ticket) error {
return s.writeUnlocked(t.ProjectID, tickets)
}
// MarkSynced sets SyncedAt = UpdatedAt without changing UpdatedAt.
func (s *JSONStore) MarkSynced(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" {
return fmt.Errorf("ticket id is required")
}
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("ticket %s: not found", id)
}
return fmt.Errorf("list projects: %w", err)
}
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return err
}
for i := range tickets {
if tickets[i].ID != id {
continue
}
tickets[i].SyncedAt = tickets[i].UpdatedAt
return s.writeUnlocked(p.Name(), tickets)
}
}
return fmt.Errorf("ticket %s: not found", id)
}
// Move relocates a ticket to another project directory.
func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" {
return Ticket{}, fmt.Errorf("ticket id is required")
}
if toProjectID == "" {
return Ticket{}, fmt.Errorf("projectId is required")
}
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
return Ticket{}, fmt.Errorf("list projects: %w", err)
}
var found Ticket
fromProject := ""
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return Ticket{}, err
}
for i, t := range tickets {
if t.ID != id {
continue
}
found = t
fromProject = p.Name()
tickets = append(tickets[:i], tickets[i+1:]...)
if err := s.writeUnlocked(fromProject, tickets); err != nil {
return Ticket{}, err
}
break
}
if fromProject != "" {
break
}
}
if fromProject == "" {
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
found.ProjectID = toProjectID
found.UpdatedAt = time.Now().UTC()
if err := os.MkdirAll(s.projectPath(toProjectID), 0o755); err != nil {
return Ticket{}, fmt.Errorf("create project dir: %w", err)
}
dest, err := s.readUnlocked(toProjectID)
if err != nil {
return Ticket{}, err
}
dest = append(dest, found)
if err := s.writeUnlocked(toProjectID, dest); err != nil {
return Ticket{}, err
}
return found, nil
}
// Delete removes a ticket by ID from its project's file.
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
+68
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"task/internal/ticket"
)
@@ -65,6 +66,43 @@ func TestJSONStoreCreateListUpdate(t *testing.T) {
}
}
func TestUpdateAfterMarkSyncedIsDirty(t *testing.T) {
store := ticket.NewJSONStore(t.TempDir())
created, err := store.Create(ticket.Ticket{
Title: "Synced", ProjectID: "inbox", Status: ticket.StatusTodo,
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
})
if err != nil {
t.Fatal(err)
}
if err := store.MarkSynced(created.ID); err != nil {
t.Fatalf("MarkSynced: %v", err)
}
synced, err := store.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if synced.UpdatedAt.After(synced.SyncedAt) {
t.Fatalf("freshly marked synced should not be dirty: updated=%v syncedAt=%v",
synced.UpdatedAt, synced.SyncedAt)
}
// Simulate UI edit: copy ticket (SyncedAt == UpdatedAt) and change title.
edited := synced
edited.Title = "Edited locally"
time.Sleep(time.Millisecond) // ensure clock can move
if err := store.Update(edited); err != nil {
t.Fatal(err)
}
got, err := store.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if !got.UpdatedAt.After(got.SyncedAt) {
t.Fatalf("edit must bump UpdatedAt past SyncedAt: %v vs %v", got.UpdatedAt, got.SyncedAt)
}
}
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
dir := t.TempDir()
projectDir := filepath.Join(dir, "projects", "inbox")
@@ -97,3 +135,33 @@ func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
}
}
func TestJSONStoreMove(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
created, err := store.Create(ticket.Ticket{
Title: "Misfiled",
ProjectID: "inbox",
})
if err != nil {
t.Fatal(err)
}
moved, err := store.Move(created.ID, "client")
if err != nil {
t.Fatal(err)
}
if moved.ProjectID != "client" {
t.Fatalf("ProjectID = %q", moved.ProjectID)
}
from, err := store.List("inbox")
if err != nil || len(from) != 0 {
t.Fatalf("inbox after move: %v len=%d", err, len(from))
}
to, err := store.List("client")
if err != nil || len(to) != 1 || to[0].ID != created.ID {
t.Fatalf("client after move: %v %+v", err, to)
}
}

Some files were not shown because too many files have changed in this diff Show More