Compare commits

...
20 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
120 changed files with 13983 additions and 236 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"tm": {
"command": "${workspaceFolder}/bin/tm",
"args": ["mcp"]
}
}
}
+6 -1
View File
@@ -29,6 +29,9 @@ jobs:
- name: Build
run: make build
- name: Build server
run: make build-server
- name: Version
run: make version
@@ -36,7 +39,9 @@ jobs:
uses: actions/upload-artifact@v3
with:
name: tm
path: ./bin/tm
path: |
./bin/tm
./bin/tm_server
- run: echo "This job's status is ${{ job.status }}."
+11
View File
@@ -1,8 +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).
+43 -7
View File
@@ -1,8 +1,10 @@
BINARY := tm
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)
# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=...
GOPATH ?= $(shell go env GOPATH)
@@ -10,18 +12,52 @@ PREFIX ?= $(GOPATH)
INSTALL_BIN := $(PREFIX)/bin
DEV_HOME := $(CURDIR)/.dev-home
.PHONY: build run run-dev install uninstall test version demo
.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)
# 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)
+95 -19
View File
@@ -1,6 +1,6 @@
# task-master
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea).
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)
@@ -8,18 +8,35 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch
## Features
- Projekte & Tickets lokal als JSON (kein Server)
**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 verwalten
- Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren
- Report mit Group-by (Ticket / Tag)
- Undo / Redo über eine Command-History
- 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`).
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
@@ -27,19 +44,70 @@ 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
tm sync pull # Issues von YouTrack holen
tm sync push # lokale Änderungen nach YouTrack
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
@@ -50,10 +118,16 @@ 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 |
| 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)
@@ -61,13 +135,12 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|---------|------------------------|
| `j`/`k` | Navigieren |
| `n` | Neues Ticket/Projekt |
| `e` | Ticket im Editor |
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack / Sync-Backend) |
| `s` | Status ändern |
| `l` | Board-Filter |
| `/` | Suche |
| `p`/`P` | Projekt / Ticket verschieben |
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack) |
| `Y` | YouTrack-Menü: Pull / Push |
| `Y` | Sync-Menü: Pull / Push |
| `t`/`T` | Timer / Zeit buchen |
| `m` | Zeiteinträge |
| `r` | Report |
@@ -77,15 +150,15 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
Checklisten im Preview: `J`/`K` + `Space`.
### YouTrack
### YouTrack / Sync
1. `p` → Projekt wählen → `e`
2. Remote-URL, Permanent Token und Project-Key setzen (gespeichert in `projects/<id>/meta.json`, Mode `0600`)
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** (Issues + Work Items) oder **Push** (Tickets + Zeiteinträge)
4. `Y` → **Pull** oder **Push**
CLI: `tm sync pull` / `tm sync push`
Zeit: lokale Einträge ohne `externalId` werden als YouTrack-Work-Items gepusht; remote Work Items landen lokal beim Pull.
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).
@@ -94,9 +167,12 @@ Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fi
```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)):
+158 -43
View File
@@ -8,13 +8,13 @@ import (
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"
"task/internal/ytsync"
)
func main() {
@@ -32,6 +32,30 @@ func main() {
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()
@@ -45,35 +69,104 @@ 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)
timeStore := ticket.NewJSONTimeStore(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, timeStore)
if err != nil {
return fmt.Errorf("history: %w", err)
}
model := ui.New(cfg, store, projects, timeStore, 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 runSync(args []string) error {
cfg, err := config.LoadOrCreate()
func runDaemon() error {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("config: %w", err)
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 := ""
@@ -104,39 +197,61 @@ func runSync(args []string) error {
return fmt.Errorf("project id required")
}
store := ticket.NewJSONStore(cfg.DataDir)
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
if err := projects.Ensure(projectID); err != nil {
return err
}
switch action {
case "push":
res, err := ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
if action == "push" {
out, err := cli.SyncPush(projectID)
if err != nil {
return err
}
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
projectID, res.Created, res.Updated, res.TimePushed)
default:
res, err := ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
if err != nil {
return err
}
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
projectID, res.Created, res.Updated, res.TimePulled, res.Total)
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 sync pull [-p ID] Pull issues from YouTrack
%s sync push [-p ID] Push local changes to YouTrack
%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
@@ -144,9 +259,9 @@ Env:
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
TASK_MASTER_DATA_DIR Override data directory only
YouTrack:
In the TUI: p → project → e to set remote URL, token, project key.
Then Y → Pull or Push (manual only; edits stay local until you push).
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, 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
`)
}
+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=
+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")
}
}
+73 -1
View File
@@ -10,6 +10,12 @@ import (
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"`
@@ -34,9 +40,66 @@ func (y YouTrack) IssueQuery() string {
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 {
YouTrack *YouTrack `json:"youtrack,omitempty"`
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 {
@@ -63,6 +126,12 @@ func (s *DirStore) LoadMeta(id string) (Meta, error) {
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
}
@@ -74,6 +143,9 @@ func (s *DirStore) SaveMeta(id string, meta Meta) error {
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 {
+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"`
}
+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
}
+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)
}
}
+60
View File
@@ -3,6 +3,7 @@ package ticket
import (
"fmt"
"sort"
"strings"
"time"
)
@@ -155,3 +156,62 @@ func TotalDuration(entries []TimeEntry) time.Duration {
}
return sum
}
// TimeFilter selects which time entries belong in a report.
// From/To are inclusive bounds on the entry's work instant (Start, else End).
// Zero times mean no bound on that side.
type TimeFilter struct {
From time.Time
To time.Time
TicketID string
}
// FilterEntries returns entries matching f. The input slice is not modified.
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
out := make([]TimeEntry, len(entries))
copy(out, entries)
return out
}
out := make([]TimeEntry, 0, len(entries))
for _, e := range entries {
if f.TicketID != "" && e.TicketID != f.TicketID {
continue
}
when := e.Start
if when.IsZero() {
when = e.End
}
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
continue
}
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
continue
}
out = append(out, e)
}
return out
}
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
func ParseReportDay(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, nil
}
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return time.Time{}, fmt.Errorf("invalid date %q", s)
}
return t, nil
}
// EndOfReportDay returns the last instant of the calendar day of t in local time.
func EndOfReportDay(t time.Time) time.Time {
if t.IsZero() {
return t
}
local := t.In(time.Local)
y, m, d := local.Date()
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
}
+45
View File
@@ -70,3 +70,48 @@ func TestAggregateByDay(t *testing.T) {
t.Fatalf("older day dur=%v", rows[1].Duration)
}
}
func TestFilterEntries(t *testing.T) {
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
d2 := time.Date(2026, 10, 20, 11, 0, 0, 0, time.Local)
entries := []ticket.TimeEntry{
{ID: "1", TicketID: "a", Start: d1, Duration: time.Hour},
{ID: "2", TicketID: "b", Start: d2, Duration: 30 * time.Minute},
{ID: "3", TicketID: "a", Start: d2, Duration: 15 * time.Minute},
}
byTicket := ticket.FilterEntries(entries, ticket.TimeFilter{TicketID: "a"})
if len(byTicket) != 2 {
t.Fatalf("ticket filter=%d", len(byTicket))
}
from := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
to := ticket.EndOfReportDay(time.Date(2026, 10, 20, 0, 0, 0, 0, time.Local))
byRange := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to})
if len(byRange) != 2 {
t.Fatalf("range filter=%d", len(byRange))
}
both := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to, TicketID: "a"})
if len(both) != 1 || both[0].ID != "3" {
t.Fatalf("combined=%+v", both)
}
}
func TestParseReportDay(t *testing.T) {
got, err := ticket.ParseReportDay("2026-10-18")
if err != nil {
t.Fatal(err)
}
y, m, d := got.Date()
if y != 2026 || m != 10 || d != 18 || got.Hour() != 0 {
t.Fatalf("got %v", got)
}
empty, err := ticket.ParseReportDay("")
if err != nil || !empty.IsZero() {
t.Fatalf("empty: %v %v", empty, err)
}
if _, err := ticket.ParseReportDay("nope"); err == nil {
t.Fatal("expected error")
}
}
+15
View File
@@ -53,6 +53,8 @@ func (s Status) Index() int {
const (
// SourceYouTrack marks tickets imported from YouTrack.
SourceYouTrack = "youtrack"
// SourceTaskMaster marks tickets synced from a task-master server.
SourceTaskMaster = "taskmaster"
)
// Ticket is the domain model for a work item.
@@ -66,9 +68,22 @@ type Ticket struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Readable key assigned by the server (e.g. "ABC-42"). Optional on local-only tickets.
Number int `json:"number,omitempty"`
Key string `json:"key,omitempty"`
// External sync (optional). Local UUID stays in ID.
Source string `json:"source,omitempty"`
ExternalID string `json:"externalId,omitempty"`
ExternalURL string `json:"externalUrl,omitempty"`
SyncedAt time.Time `json:"syncedAt,omitempty"`
}
// DisplayID returns the human-readable ticket label for UI.
// Prefers the server key; falls back to ExternalID (e.g. YouTrack idReadable).
func (t Ticket) DisplayID() string {
if t.Key != "" {
return t.Key
}
return t.ExternalID
}
+336
View File
@@ -0,0 +1,336 @@
package tmsync
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"task/internal/project"
"task/internal/ticket"
"task/internal/ytsync"
)
// Result reuses ytsync.Result for consistent CLI/UI messaging.
type Result = ytsync.Result
// Syncer syncs a local project with a remote tm_server project.
type Syncer struct {
Store ticket.Store
TimeStore ticket.TimeStore
HTTP *http.Client
}
func (s *Syncer) http() *http.Client {
if s.HTTP != nil {
return s.HTTP
}
return http.DefaultClient
}
// NeedsPush reports whether a local ticket should be pushed to task-master.
func NeedsPush(t ticket.Ticket) bool {
if t.ExternalID == "" {
return true
}
if t.Source != ticket.SourceTaskMaster {
return false
}
if t.SyncedAt.IsZero() {
return true
}
return t.UpdatedAt.After(t.SyncedAt)
}
// Pull imports remote tickets/time into localProjectID.
func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
if !tm.Configured() {
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
remote, err := s.listTickets(ctx, tm)
if err != nil {
return Result{}, err
}
existing, err := s.Store.List(localProjectID)
if err != nil {
return Result{}, err
}
byExt := map[string]ticket.Ticket{}
for _, t := range existing {
if t.ExternalID != "" {
byExt[t.ExternalID] = t
}
}
var res Result
res.Total = len(remote)
for _, rt := range remote {
if rt.ID == "" {
res.Skipped++
continue
}
extURL := strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + rt.ID
if prev, ok := byExt[rt.ID]; ok {
if NeedsPush(prev) {
res.Skipped++
continue
}
next := prev
next.Title = rt.Title
next.Description = rt.Description
next.Status = rt.Status
next.Source = ticket.SourceTaskMaster
next.ExternalURL = extURL
next.Number = rt.Number
next.Key = rt.Key
if sameFields(prev, next) {
_ = s.Store.MarkSynced(prev.ID)
res.Skipped++
continue
}
if err := s.Store.Update(next); err != nil {
return res, err
}
_ = s.Store.MarkSynced(next.ID)
res.Updated++
continue
}
now := time.Now().UTC()
_, err := s.Store.Create(ticket.Ticket{
Title: rt.Title, Description: rt.Description, Status: rt.Status,
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
ExternalID: rt.ID, ExternalURL: extURL,
Number: rt.Number, Key: rt.Key,
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
})
if err != nil {
return res, err
}
res.Created++
}
if err := s.pullTime(ctx, tm, localProjectID, &res); err != nil {
return res, err
}
return res, nil
}
// Push sends dirty local tickets/time to the remote project.
func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
if !tm.Configured() {
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
existing, err := s.Store.List(localProjectID)
if err != nil {
return Result{}, err
}
var res Result
for _, t := range existing {
if !NeedsPush(t) {
res.Skipped++
continue
}
res.Total++
if t.ExternalID == "" {
created, err := s.createRemote(ctx, tm, t)
if err != nil {
return res, err
}
t.ExternalID = created.ID
t.Number = created.Number
t.Key = created.Key
t.Source = ticket.SourceTaskMaster
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
if err := s.Store.Update(t); err != nil {
return res, err
}
_ = s.Store.MarkSynced(t.ID)
res.Created++
res.Pushed++
continue
}
if err := s.patchRemote(ctx, tm, t); err != nil {
return res, err
}
_ = s.Store.MarkSynced(t.ID)
res.Updated++
res.Pushed++
}
if err := s.pushTime(ctx, tm, localProjectID, &res); err != nil {
return res, err
}
return res, nil
}
func sameFields(a, b ticket.Ticket) bool {
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status &&
a.Key == b.Key && a.Number == b.Number
}
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
var out []ticket.Ticket
err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/tickets", nil, &out)
return out, err
}
func (s *Syncer) createRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) (ticket.Ticket, error) {
body := map[string]any{
"title": t.Title, "description": t.Description, "status": t.Status,
}
var created ticket.Ticket
err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/tickets", body, &created)
return created, err
}
func (s *Syncer) patchRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) error {
body := map[string]any{
"title": t.Title, "description": t.Description, "status": t.Status,
}
return s.doJSON(ctx, tm, http.MethodPatch,
"/projects/"+tm.ProjectID+"/tickets/"+t.ExternalID, body, nil)
}
func (s *Syncer) pullTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
var remote []ticket.TimeEntry
if err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/time", nil, &remote); err != nil {
return err
}
localTickets, err := s.Store.List(localProjectID)
if err != nil {
return err
}
localByExt := map[string]ticket.Ticket{}
for _, t := range localTickets {
if t.ExternalID != "" {
localByExt[t.ExternalID] = t
}
}
existing, err := s.TimeStore.List()
if err != nil {
return err
}
have := map[string]bool{}
for _, e := range existing {
if e.ExternalID != "" {
have[e.ExternalID] = true
}
}
for _, e := range remote {
if e.ID == "" || have[e.ID] {
continue
}
lt, ok := localByExt[e.TicketID]
if !ok {
continue
}
_, err := s.TimeStore.Create(ticket.TimeEntry{
TicketID: lt.ID, ProjectID: localProjectID,
Start: e.Start, End: e.End, Duration: e.Duration,
Source: ticket.SourceTaskMaster, Note: e.Note, ExternalID: e.ID,
})
if err != nil {
return err
}
res.TimePulled++
}
return nil
}
func (s *Syncer) pushTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(localProjectID)
if err != nil {
return err
}
byID := map[string]ticket.Ticket{}
for _, t := range tickets {
byID[t.ID] = t
}
entries, err := s.TimeStore.List()
if err != nil {
return err
}
for _, e := range entries {
if e.ProjectID != localProjectID || e.ExternalID != "" {
continue
}
t, ok := byID[e.TicketID]
if !ok || t.ExternalID == "" {
continue
}
body := map[string]any{
"ticketId": t.ExternalID,
"durationNs": int64(e.Duration),
"note": e.Note,
"source": e.Source,
}
var created ticket.TimeEntry
if err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/time", body, &created); err != nil {
return err
}
e.ExternalID = created.ID
if err := s.TimeStore.Update(e); err != nil {
return err
}
res.TimePushed++
}
return nil
}
func (s *Syncer) doJSON(ctx context.Context, tm project.TaskMaster, method, path string, body any, dest any) error {
var rdr io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(data)
}
url := strings.TrimRight(strings.TrimSpace(tm.BaseURL), "/") + "/api/v1" + path
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tm.Token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := s.http().Do(req)
if err != nil {
return fmt.Errorf("taskmaster: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var eresp struct {
Error string `json:"error"`
}
_ = json.Unmarshal(data, &eresp)
msg := eresp.Error
if msg == "" {
msg = string(data)
}
return fmt.Errorf("taskmaster: %s: %s", resp.Status, msg)
}
if dest == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, dest)
}
+132 -32
View File
@@ -23,6 +23,19 @@ import (
"task/internal/ytsync"
)
// DaemonChangedMsg is sent when the daemon publishes a change event (SSE).
type DaemonChangedMsg struct {
ProjectID string
}
// RemoteOps is the optional daemon client surface for sync and MCP tokens.
type RemoteOps interface {
SyncPull(projectID string) (ytsync.Result, error)
SyncPush(projectID string) (ytsync.Result, error)
CreateMCPToken(projectID string) (token, createdAt string, err error)
RevokeMCPToken(projectID string) error
}
// timerTickMsg refreshes the header while a timer is running.
type timerTickMsg time.Time
@@ -87,6 +100,7 @@ type Model struct {
timeEntries views.TimeEntriesView
timeEntriesTicket string // ticket ID for ModeTimeEntries
editingTimeEntry ticket.TimeEntry
timeEdit views.TimeEditForm
activeTimer *ticket.ActiveTimer
spentByID map[string]time.Duration
width int
@@ -104,10 +118,11 @@ type Model struct {
projectEdit views.ProjectEditForm
projectEditMeta project.Meta // snapshot when opening the form
syncing bool
remote RemoteOps
}
// New creates the root UI model.
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model {
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor, remote RemoteOps) Model {
styles := DefaultStyles()
if exec == nil {
exec = command.NewHistory()
@@ -119,6 +134,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, timeSto
timeStore: timeStore,
projects: projects,
exec: exec,
remote: remote,
mode: ModeList,
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
reportGroupBy: ticket.GroupByProject,
@@ -165,6 +181,15 @@ func timerTickCmd() tea.Cmd {
// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case DaemonChangedMsg:
_ = m.reloadTickets()
if m.activeTimer != nil && m.timeStore != nil {
if t, err := m.timeStore.LoadActiveTimer(); err == nil {
m.activeTimer = t
}
}
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
@@ -206,8 +231,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch m.mode {
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
case ModeCreate, ModeSearch, ModeTimeManual:
return m.updateInput(msg)
case ModeTimeEdit:
return m.updateTimeEdit(msg)
case ModeEditProject:
return m.updateEditProject(msg)
case ModeProjects:
@@ -237,8 +264,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.mode {
case ModeProjects, ModeMoveTicket:
m.projectList, cmd = m.projectList.Update(msg)
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
case ModeCreate, ModeSearch, ModeTimeManual:
m.input, cmd = m.input.Update(msg)
case ModeTimeEdit:
cmd = m.timeEdit.Update(msg)
case ModeEditProject:
cmd = m.projectEdit.Update(msg)
}
@@ -584,8 +613,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case wasProject:
m.mode = ModeProjects
case m.mode == ModeTimeEdit:
m.mode = ModeTimeEntries
default:
m.mode = ModeList
}
@@ -607,16 +634,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Enter):
value := strings.TrimSpace(m.input.Value())
if m.mode == ModeTimeEdit {
if err := m.saveEditedTime(value); err != nil {
m.setError(err)
return m, nil
}
m.input.Blur()
m.mode = ModeTimeEntries
m.setStatus("time updated")
return m, nil
}
if m.mode == ModeTimeManual {
if err := m.addManualTime(value); err != nil {
m.setError(err)
@@ -707,10 +724,8 @@ func (m Model) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.editingTimeEntry = e
m.timeEdit = views.NewTimeEditForm(e, m.width)
m.mode = ModeTimeEdit
m.input = views.NewTimeManualInput()
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Undo):
@@ -826,6 +841,14 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.projectEdit.PrevField()
return m, textinput.Blink
case msg.Type == tea.KeyLeft:
m.projectEdit.CycleBackend(-1)
return m, nil
case msg.Type == tea.KeyRight:
m.projectEdit.CycleBackend(1)
return m, nil
case key.Matches(msg, m.keys.Enter):
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
m.setError(err)
@@ -842,10 +865,10 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.mode = ModeProjects
if meta.YouTrack != nil && meta.YouTrack.Configured() {
m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id))
if meta.SyncConfigured() {
m.setStatus(fmt.Sprintf("saved %s sync for %s", meta.EffectiveBackend(), id))
} else {
m.setStatus(fmt.Sprintf("cleared YouTrack settings for %s", id))
m.setStatus(fmt.Sprintf("cleared sync settings for %s", id))
}
return m, nil
}
@@ -866,12 +889,21 @@ func (m *Model) openProjectEdit(projectID string) error {
func (m *Model) openSyncMenu(projectID string) {
m.syncProjectID = projectID
label := "Sync"
if meta, err := m.projects.LoadMeta(projectID); err == nil {
switch meta.EffectiveBackend() {
case project.BackendTaskMaster:
label = "task-master"
case project.BackendYouTrack:
label = "YouTrack"
}
}
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
})
}, label)
m.mode = ModeSync
}
@@ -911,6 +943,10 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
m.setStatus("sync already running…")
return m, nil
}
if m.remote == nil {
m.setError(fmt.Errorf("daemon client not available"))
return m, nil
}
m.clearStatus()
m.syncing = true
switch action {
@@ -919,17 +955,15 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
default:
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
}
store := m.store
timeStore := m.timeStore
projects := m.projects
remote := m.remote
return m, func() tea.Msg {
var pull, push ytsync.Result
var err error
switch action {
case views.SyncActionPush:
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
push, err = remote.SyncPush(projectID)
default:
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
pull, err = remote.SyncPull(projectID)
}
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
}
@@ -1006,6 +1040,41 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.openSyncMenu(item.Project.ID)
return m, nil
case key.Matches(msg, m.keys.MCPToken):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
if m.remote == nil {
m.setError(fmt.Errorf("daemon client not available"))
return m, nil
}
token, _, err := m.remote.CreateMCPToken(item.Project.ID)
if err != nil {
m.setError(err)
return m, nil
}
m.setStatus(fmt.Sprintf("MCP token for %s (save as .tm_token): project=%s token=%s", item.Project.ID, item.Project.ID, token))
return m, nil
case key.Matches(msg, m.keys.MCPTokenRevoke):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
if m.remote == nil {
m.setError(fmt.Errorf("daemon client not available"))
return m, nil
}
if err := m.remote.RevokeMCPToken(item.Project.ID); err != nil {
m.setError(err)
return m, nil
}
m.setStatus(fmt.Sprintf("revoked MCP token for %s", item.Project.ID))
return m, nil
case key.Matches(msg, m.keys.DeleteForce):
m.clearStatus()
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
@@ -1451,7 +1520,7 @@ func (m *Model) openProjects() error {
if err != nil {
return err
}
yt[p.ID] = meta.YouTrack != nil && meta.YouTrack.Configured()
yt[p.ID] = meta.SyncConfigured()
}
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
return nil
@@ -1683,18 +1752,49 @@ func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error {
return nil
}
func (m *Model) saveEditedTime(raw string) error {
func (m Model) updateTimeEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeTimeEntries
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Tab), msg.Type == tea.KeyDown:
m.timeEdit.NextField()
return m, textinput.Blink
case key.Matches(msg, m.keys.ShiftTab), msg.Type == tea.KeyUp:
m.timeEdit.PrevField()
return m, textinput.Blink
case key.Matches(msg, m.keys.Enter):
m.clearStatus()
if err := m.saveEditedTime(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeTimeEntries
m.setStatus("time updated")
return m, nil
}
cmd := m.timeEdit.Update(msg)
return m, cmd
}
func (m *Model) saveEditedTime() error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")
}
dur, err := ticket.ParseDurationFlexible(raw)
start, end, err := m.timeEdit.Parse()
if err != nil {
return err
}
before := m.editingTimeEntry
after := before
after.Duration = dur
after.End = after.Start.Add(dur)
after.Start = start
after.End = end
after.Duration = end.Sub(start)
if err := m.exec.Do(&command.UpdateTimeEntry{
Store: m.timeStore,
Before: before,
+53 -34
View File
@@ -34,7 +34,7 @@ func (m Model) helpEntries() []helpEntry {
{"enter", "select"},
{"n", "new project"},
{"e", "edit"},
{"Y", "YouTrack"},
{"Y", "Sync"},
{"x", "delete"},
{"X", "force delete"},
{"esc", "cancel"},
@@ -87,13 +87,14 @@ func (m Model) helpEntries() []helpEntry {
case ModeTimeEntries:
return []helpEntry{
{"j/k", "navigate"},
{"e", "edit"},
{"e", "edit times"},
{"x", "delete"},
{"u/U", "undo/redo"},
{"esc", "back"},
}
case ModeTimeEdit:
return []helpEntry{
{"tab/↑↓", "fields"},
{"enter", "save"},
{"esc", "cancel"},
}
@@ -121,7 +122,7 @@ func (m Model) helpEntries() []helpEntry {
{"t/T", "timer/log"},
{"m", "entries"},
{"r", "report"},
{"Y", "YouTrack"},
{"Y", "Sync"},
{"u/U", "undo/redo"},
{"/", "search"},
{"p", "project"},
@@ -151,42 +152,60 @@ func (m Model) helpSep() string {
return sep
}
// wrapHelpParts packs styled help snippets into lines that fit within width.
func wrapHelpParts(parts []string, width int, sep string) []string {
// padHelpCell pads a styled help snippet to cellW visible columns.
func padHelpCell(part string, cellW int) string {
pad := cellW - lipgloss.Width(part)
if pad < 0 {
pad = 0
}
return part + strings.Repeat(" ", pad)
}
// tableHelpParts lays out styled help snippets as a fixed-width column grid.
func tableHelpParts(parts []string, width int, sep string) []string {
if len(parts) == 0 {
return []string{""}
}
if width < 1 {
width = 1
}
sepW := lipgloss.Width(sep)
var lines []string
var cur string
cellW := 0
for _, p := range parts {
pw := lipgloss.Width(p)
if cur == "" {
cur = p
// Oversized single entry still gets its own line so nothing is dropped.
if pw > width {
lines = append(lines, cur)
cur = ""
if w := lipgloss.Width(p); w > cellW {
cellW = w
}
}
if cellW < 1 {
cellW = 1
}
sepW := lipgloss.Width(sep)
cols := 1
if cellW <= width {
cols = (width + sepW) / (cellW + sepW)
if cols < 1 {
cols = 1
}
}
var lines []string
for i := 0; i < len(parts); i += cols {
end := i + cols
if end > len(parts) {
end = len(parts)
}
row := parts[i:end]
cells := make([]string, len(row))
for j, p := range row {
// Pad every cell except a lone oversized entry on its own row.
if len(row) == 1 && lipgloss.Width(p) > width {
cells[j] = p
} else {
cells[j] = padHelpCell(p, cellW)
}
continue
}
if lipgloss.Width(cur)+sepW+pw <= width {
cur = cur + sep + p
continue
}
lines = append(lines, cur)
cur = p
if pw > width {
lines = append(lines, cur)
cur = ""
}
}
if cur != "" {
lines = append(lines, cur)
}
if len(lines) == 0 {
return []string{""}
lines = append(lines, strings.Join(cells, sep))
}
return lines
}
@@ -202,8 +221,8 @@ func (m Model) renderFooter() string {
sep := m.helpSep()
parts := m.helpParts()
// Wrap help across the full width so all bindings stay visible.
lines := wrapHelpParts(parts, width, sep)
// Table layout so key/desc columns stay aligned across rows.
lines := tableHelpParts(parts, width, sep)
// Place version on the last line to the right when it fits; otherwise
// put it on an extra right-aligned line.
+11 -1
View File
@@ -25,6 +25,8 @@ type KeyMap struct {
TimeEntries key.Binding // m — manage time entries
Report key.Binding // r — time report
Sync key.Binding // Y — pull YouTrack issues
MCPToken key.Binding // g — create MCP token for project
MCPTokenRevoke key.Binding // G — revoke MCP token
Undo key.Binding
Redo key.Binding
Quit key.Binding
@@ -121,6 +123,14 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("Y"),
key.WithHelp("Y", "YouTrack pull/push"),
),
MCPToken: key.NewBinding(
key.WithKeys("g"),
key.WithHelp("g", "MCP token"),
),
MCPTokenRevoke: key.NewBinding(
key.WithKeys("G"),
key.WithHelp("G", "revoke MCP token"),
),
Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"),
@@ -154,7 +164,7 @@ func DefaultKeyMap() KeyMap {
// ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.MCPToken, k.MCPTokenRevoke, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
}
// FullHelp implements help.KeyMap.
+1 -3
View File
@@ -60,9 +60,7 @@ func (m Model) renderBody(height int) string {
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeTimeEdit:
prompt := m.styles.InputPrompt.Render("Edit duration:")
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.timeEdit.View())
case ModeTimeEntries:
m.timeEntries.SetSize(m.width, height)
+190 -54
View File
@@ -11,7 +11,7 @@ import (
"task/internal/project"
)
// ProjectEdit field indices.
// ProjectEdit field indices (backend picker is separate).
const (
FieldRemoteURL = iota
FieldToken
@@ -19,6 +19,12 @@ const (
ProjectEditFieldCount
)
var backendOptions = []string{
project.BackendNone,
project.BackendYouTrack,
project.BackendTaskMaster,
}
// ProjectEditStyles styles the project edit form.
type ProjectEditStyles struct {
Label lipgloss.Style
@@ -39,9 +45,12 @@ func DefaultProjectEditStyles() ProjectEditStyles {
}
}
// ProjectEditForm is a multi-field editor for YouTrack project settings.
// ProjectEditForm is a multi-field editor for sync backend settings.
type ProjectEditForm struct {
ProjectID string
Backend string
BackendIdx int
FocusBackend bool // true when selecting backend
Inputs []textinput.Model
Focus int
Styles ProjectEditStyles
@@ -67,34 +76,82 @@ func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectE
return ti
}
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
inputs[FieldToken] = mk("permanent token", 512, true)
inputs[FieldProjectKey] = mk("PROJ", 64, false)
backend := meta.EffectiveBackend()
if backend == "" {
backend = project.BackendNone
}
bIdx := 0
for i, b := range backendOptions {
if b == backend {
bIdx = i
break
}
}
hadToken := false
if meta.YouTrack != nil {
yt := meta.YouTrack
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
if yt.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
switch backend {
case project.BackendTaskMaster:
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
inputs[FieldToken] = mk("tm_… project token", 512, true)
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
if meta.TaskMaster != nil {
tm := meta.TaskMaster
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
inputs[FieldProjectKey].SetValue(tm.ProjectID)
if tm.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
}
}
default:
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
inputs[FieldToken] = mk("permanent token", 512, true)
inputs[FieldProjectKey] = mk("PROJ", 64, false)
if meta.YouTrack != nil {
yt := meta.YouTrack
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
if yt.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
}
}
}
f := ProjectEditForm{
ProjectID: projectID,
Inputs: inputs,
Focus: FieldRemoteURL,
Styles: styles,
Width: width,
hadToken: hadToken,
ProjectID: projectID,
Backend: backend,
BackendIdx: bIdx,
FocusBackend: true,
Inputs: inputs,
Focus: FieldRemoteURL,
Styles: styles,
Width: width,
hadToken: hadToken,
}
f.focusIndex(FieldRemoteURL)
return f
}
func (f *ProjectEditForm) applyBackendPlaceholders() {
f.Backend = backendOptions[f.BackendIdx]
switch f.Backend {
case project.BackendTaskMaster:
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
f.Inputs[FieldToken].Placeholder = "tm_… project token"
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
case project.BackendYouTrack:
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
f.Inputs[FieldToken].Placeholder = "permanent token"
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
default:
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
f.Inputs[FieldToken].Placeholder = "(unused)"
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
}
}
func (f *ProjectEditForm) focusIndex(i int) {
f.FocusBackend = false
f.Focus = i
for idx := range f.Inputs {
if idx == i {
@@ -111,16 +168,52 @@ func (f *ProjectEditForm) focusIndex(i int) {
// NextField moves focus to the next field.
func (f *ProjectEditForm) NextField() {
if f.FocusBackend {
if f.Backend == project.BackendNone {
return
}
f.focusIndex(FieldRemoteURL)
return
}
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
}
// PrevField moves focus to the previous field.
func (f *ProjectEditForm) PrevField() {
if f.FocusBackend {
return
}
if f.Focus == FieldRemoteURL {
f.blurInputs()
f.FocusBackend = true
return
}
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
}
func (f *ProjectEditForm) blurInputs() {
for idx := range f.Inputs {
f.Inputs[idx].Blur()
f.Inputs[idx].PromptStyle = f.Styles.Blurred
f.Inputs[idx].TextStyle = f.Styles.Blurred
}
}
// CycleBackend changes the selected backend when focusing the picker.
func (f *ProjectEditForm) CycleBackend(delta int) {
if !f.FocusBackend {
return
}
n := len(backendOptions)
f.BackendIdx = (f.BackendIdx + delta + n) % n
f.applyBackendPlaceholders()
}
// Update forwards a message to the focused text input.
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
if f.FocusBackend || f.Backend == project.BackendNone {
return nil
}
var cmd tea.Cmd
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
@@ -131,60 +224,103 @@ func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
// Meta builds the Meta to persist. Empty token keeps the previous token.
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
backend := backendOptions[f.BackendIdx]
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
if baseURL == "" && token == "" && key == "" {
return project.Meta{YouTrack: nil}
switch backend {
case project.BackendNone:
return project.Meta{Backend: project.BackendNone}
case project.BackendTaskMaster:
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
if token == "" && previous.TaskMaster != nil {
tm.Token = previous.TaskMaster.Token
}
if baseURL == "" && token == "" && key == "" {
return project.Meta{Backend: project.BackendNone}
}
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
default:
yt := &project.YouTrack{BaseURL: baseURL, ProjectKey: key, Token: token}
if token == "" && previous.YouTrack != nil {
yt.Token = previous.YouTrack.Token
}
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
yt.Query = previous.YouTrack.Query
}
if baseURL == "" && token == "" && key == "" {
return project.Meta{Backend: project.BackendNone}
}
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
}
yt := &project.YouTrack{
BaseURL: baseURL,
ProjectKey: key,
Token: token,
}
if token == "" && previous.YouTrack != nil {
yt.Token = previous.YouTrack.Token
}
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
yt.Query = previous.YouTrack.Query
}
return project.Meta{YouTrack: yt}
}
// Validate ensures partial YouTrack configs are rejected.
// Validate ensures partial configs are rejected.
func (f ProjectEditForm) Validate(previous project.Meta) error {
meta := f.Meta(previous)
if meta.YouTrack == nil {
switch meta.EffectiveBackend() {
case project.BackendNone:
return nil
case project.BackendTaskMaster:
tm := meta.TaskMaster
if tm == nil {
return nil
}
if !tm.Configured() {
return fmt.Errorf("incomplete taskmaster config: need base URL, token, project id")
}
return nil
default:
yt := meta.YouTrack
if yt == nil {
return nil
}
if !yt.Configured() {
return fmt.Errorf("incomplete YouTrack config: need remote URL, token, project key")
}
return nil
}
yt := meta.YouTrack
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != ""
if any && !yt.Configured() {
missing := []string{}
if yt.BaseURL == "" {
missing = append(missing, "remote URL")
}
if yt.Token == "" {
missing = append(missing, "token")
}
if yt.ProjectKey == "" {
missing = append(missing, "project key")
}
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
}
return nil
}
// View renders the form.
func (f ProjectEditForm) View() string {
labels := []string{"Remote URL", "Token", "Project key"}
var b strings.Builder
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
b.WriteString("\n")
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel"))
b.WriteString(f.Styles.Hint.Render("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
b.WriteString("\n\n")
backendLine := "Backend: "
for i, opt := range backendOptions {
label := opt
if i == f.BackendIdx {
if f.FocusBackend {
label = f.Styles.Focused.Render("[" + opt + "]")
} else {
label = f.Styles.Focused.Render(opt)
}
} else {
label = f.Styles.Blurred.Render(opt)
}
if i > 0 {
backendLine += " "
}
backendLine += label
}
b.WriteString(backendLine)
b.WriteString("\n\n")
if f.Backend == project.BackendNone {
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
b.WriteString("\n")
return b.String()
}
labels := []string{"Remote URL", "Token", "Project key"}
if f.Backend == project.BackendTaskMaster {
labels = []string{"Server URL", "Project token", "Remote project ID"}
}
for i, label := range labels {
b.WriteString(f.Styles.Label.Render(label + ":"))
b.WriteString("\n")
+19 -1
View File
@@ -9,6 +9,7 @@ import (
func TestProjectEditMetaKeepsToken(t *testing.T) {
prev := project.Meta{
Backend: project.BackendYouTrack,
YouTrack: &project.YouTrack{
BaseURL: "https://yt.example",
Token: "secret",
@@ -31,10 +32,27 @@ func TestProjectEditMetaKeepsToken(t *testing.T) {
}
func TestProjectEditValidateIncomplete(t *testing.T) {
f := views.NewProjectEditForm("inbox", project.Meta{}, 80)
f := views.NewProjectEditForm("inbox", project.Meta{Backend: project.BackendYouTrack}, 80)
f.BackendIdx = 1 // youtrack
f.Backend = project.BackendYouTrack
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
// missing token + key
if err := f.Validate(project.Meta{}); err == nil {
t.Fatal("expected incomplete error")
}
}
func TestProjectEditTaskMaster(t *testing.T) {
prev := project.Meta{
Backend: project.BackendTaskMaster,
TaskMaster: &project.TaskMaster{
BaseURL: "http://127.0.0.1:8080", Token: "tm_secret", ProjectID: "abc",
},
}
f := views.NewProjectEditForm("inbox", prev, 80)
f.Inputs[views.FieldToken].SetValue("")
meta := f.Meta(prev)
if meta.TaskMaster == nil || meta.TaskMaster.Token != "tm_secret" {
t.Fatalf("expected kept tm token, got %+v", meta.TaskMaster)
}
}
+22 -19
View File
@@ -6,7 +6,7 @@ import (
"unicode"
)
// SyncAction is a YouTrack sync menu choice.
// SyncAction is a sync menu choice.
type SyncAction string
const (
@@ -21,27 +21,30 @@ type SyncOption struct {
Shortcut rune
}
// AllSyncOptions is the ordered sync menu.
var AllSyncOptions = []SyncOption{
{Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'},
{Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'},
}
// SyncMenu renders a pull/push picker overlay.
type SyncMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
Cursor int
Width int
Title string
Options []SyncOption
Styles StatusMenuStyles
}
// NewSyncMenu creates a sync action menu.
func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu {
return SyncMenu{Cursor: 0, Width: width, Styles: styles}
// NewSyncMenu creates a sync action menu for the given backend label.
func NewSyncMenu(width int, styles StatusMenuStyles, backendLabel string) SyncMenu {
if backendLabel == "" {
backendLabel = "Sync"
}
opts := []SyncOption{
{Action: SyncActionPull, Label: "Pull from " + backendLabel, Shortcut: 'p'},
{Action: SyncActionPush, Label: "Push to " + backendLabel, Shortcut: 'u'},
}
return SyncMenu{Cursor: 0, Width: width, Title: backendLabel, Options: opts, Styles: styles}
}
// MoveDown moves the selection down.
func (m *SyncMenu) MoveDown() {
if m.Cursor < len(AllSyncOptions)-1 {
if m.Cursor < len(m.Options)-1 {
m.Cursor++
}
}
@@ -56,7 +59,7 @@ func (m *SyncMenu) MoveUp() {
// JumpToShortcut selects the option whose shortcut matches r.
func (m *SyncMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
for i, opt := range AllSyncOptions {
for i, opt := range m.Options {
if opt.Shortcut == r {
m.Cursor = i
return true
@@ -67,17 +70,17 @@ func (m *SyncMenu) JumpToShortcut(r rune) bool {
// Selected returns the highlighted action.
func (m SyncMenu) Selected() SyncAction {
if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) {
if m.Cursor < 0 || m.Cursor >= len(m.Options) {
return SyncActionPull
}
return AllSyncOptions[m.Cursor].Action
return m.Options[m.Cursor].Action
}
// View renders the sync menu.
func (m SyncMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("YouTrack"))
for i, opt := range AllSyncOptions {
lines = append(lines, m.Styles.Title.Render(m.Title))
for i, opt := range m.Options {
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
if i == m.Cursor {
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
+2 -2
View File
@@ -177,8 +177,8 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
badge := l.statusBadge(t.Status)
ext := ""
if t.ExternalID != "" {
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
if id := t.DisplayID(); id != "" {
ext = lipgloss.NewStyle().Faint(true).Render(id) + " "
}
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
+154
View File
@@ -0,0 +1,154 @@
package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// Time edit field indices.
const (
FieldTimeStart = iota
FieldTimeEnd
TimeEditFieldCount
)
const timeEditLayout = "2006-01-02 15:04"
// TimeEditStyles styles the time-entry edit form.
type TimeEditStyles struct {
Label lipgloss.Style
Focused lipgloss.Style
Blurred lipgloss.Style
Hint lipgloss.Style
Title lipgloss.Style
}
// DefaultTimeEditStyles returns sensible defaults.
func DefaultTimeEditStyles() TimeEditStyles {
return TimeEditStyles{
Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")),
Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
Title: lipgloss.NewStyle().Bold(true),
}
}
// TimeEditForm edits start and end of a time entry.
type TimeEditForm struct {
Inputs []textinput.Model
Focus int
Styles TimeEditStyles
Width int
}
// NewTimeEditForm builds a form prefilled from an entry.
func NewTimeEditForm(e ticket.TimeEntry, width int) TimeEditForm {
styles := DefaultTimeEditStyles()
inputs := make([]textinput.Model, TimeEditFieldCount)
mk := func(placeholder string) textinput.Model {
ti := textinput.New()
ti.Placeholder = placeholder
ti.CharLimit = 16
ti.Width = max(20, width-10)
return ti
}
inputs[FieldTimeStart] = mk(timeEditLayout)
inputs[FieldTimeEnd] = mk(timeEditLayout)
if !e.Start.IsZero() {
inputs[FieldTimeStart].SetValue(e.Start.Local().Format(timeEditLayout))
}
if !e.End.IsZero() {
inputs[FieldTimeEnd].SetValue(e.End.Local().Format(timeEditLayout))
} else if !e.Start.IsZero() && e.Duration > 0 {
inputs[FieldTimeEnd].SetValue(e.Start.Add(e.Duration).Local().Format(timeEditLayout))
}
f := TimeEditForm{
Inputs: inputs,
Focus: FieldTimeStart,
Styles: styles,
Width: width,
}
f.focusIndex(FieldTimeStart)
return f
}
func (f *TimeEditForm) focusIndex(i int) {
f.Focus = i
for idx := range f.Inputs {
if idx == i {
f.Inputs[idx].Focus()
f.Inputs[idx].PromptStyle = f.Styles.Focused
f.Inputs[idx].TextStyle = f.Styles.Focused
} else {
f.Inputs[idx].Blur()
f.Inputs[idx].PromptStyle = f.Styles.Blurred
f.Inputs[idx].TextStyle = f.Styles.Blurred
}
}
}
// NextField moves focus to the next field.
func (f *TimeEditForm) NextField() {
f.focusIndex((f.Focus + 1) % TimeEditFieldCount)
}
// PrevField moves focus to the previous field.
func (f *TimeEditForm) PrevField() {
f.focusIndex((f.Focus - 1 + TimeEditFieldCount) % TimeEditFieldCount)
}
// Update forwards a message to the focused text input.
func (f *TimeEditForm) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
return cmd
}
// Parse returns start and end in UTC, or an error.
func (f TimeEditForm) Parse() (start, end time.Time, err error) {
startRaw := strings.TrimSpace(f.Inputs[FieldTimeStart].Value())
endRaw := strings.TrimSpace(f.Inputs[FieldTimeEnd].Value())
if startRaw == "" || endRaw == "" {
return time.Time{}, time.Time{}, fmt.Errorf("start and end are required")
}
start, err = time.ParseInLocation(timeEditLayout, startRaw, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("start: use %s", timeEditLayout)
}
end, err = time.ParseInLocation(timeEditLayout, endRaw, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("end: use %s", timeEditLayout)
}
if !end.After(start) {
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
}
return start.UTC(), end.UTC(), nil
}
// View renders the form.
func (f TimeEditForm) View() string {
var b strings.Builder
b.WriteString(f.Styles.Title.Render("Edit time entry"))
b.WriteString("\n\n")
labels := []string{"Start", "End"}
for i, label := range labels {
b.WriteString(f.Styles.Label.Render(label+":"))
b.WriteString(" ")
b.WriteString(f.Inputs[i].View())
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(f.Styles.Hint.Render("format " + timeEditLayout + " · tab fields · enter save · esc cancel"))
return b.String()
}
+34
View File
@@ -0,0 +1,34 @@
package views
import (
"testing"
"time"
"task/internal/ticket"
)
func TestTimeEditFormParse(t *testing.T) {
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
end := start.Add(90 * time.Minute)
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: end, Duration: 90 * time.Minute}, 80)
gotStart, gotEnd, err := f.Parse()
if err != nil {
t.Fatal(err)
}
if !gotStart.Equal(start.UTC()) {
t.Fatalf("start: got %v want %v", gotStart, start.UTC())
}
if !gotEnd.Equal(end.UTC()) {
t.Fatalf("end: got %v want %v", gotEnd, end.UTC())
}
}
func TestTimeEditFormRejectsBadRange(t *testing.T) {
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: start, Duration: time.Minute}, 80)
f.Inputs[FieldTimeEnd].SetValue(start.Format(timeEditLayout))
if _, _, err := f.Parse(); err == nil {
t.Fatal("expected error for end == start")
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ func (v TimeEntriesView) View() string {
title = "ticket"
}
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
hint := v.Styles.Hint.Render("j/k navigate · e edit start/end · x delete · esc back")
innerW := max(20, v.Width-2)
var lines []string
View File
+18
View File
@@ -0,0 +1,18 @@
package webembed
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// FS returns the embedded static frontend filesystem (contents of dist/).
func FS() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return dist
}
return sub
}
+43
View File
@@ -516,6 +516,49 @@ func (c *Client) findProjectViaIssue(ctx context.Context, shortName string) (str
return issues[0].Project.ID, nil
}
// Project is a YouTrack project summary for the register/import UI.
type Project struct {
ID string
ShortName string
Name string
}
// ListProjects lists admin-accessible projects (requires sufficient token scope).
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
if err := c.ensureAuth(); err != nil {
return nil, err
}
var all []Project
for page := 0; page < maxPages; page++ {
skip := page * defaultPageSize
u, err := url.Parse(c.base() + "/api/admin/projects")
if err != nil {
return nil, err
}
q := u.Query()
q.Set("fields", "id,shortName,name")
q.Set("$top", fmt.Sprintf("%d", defaultPageSize))
q.Set("$skip", fmt.Sprintf("%d", skip))
u.RawQuery = q.Encode()
var raw []struct {
ID string `json:"id"`
ShortName string `json:"shortName"`
Name string `json:"name"`
}
if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil {
return nil, err
}
for _, p := range raw {
all = append(all, Project{ID: p.ID, ShortName: p.ShortName, Name: p.Name})
}
if len(raw) < defaultPageSize {
break
}
}
return all, nil
}
// IssueURL builds the browser URL for an issue.
func (c *Client) IssueURL(idReadable string) string {
return c.base() + "/issue/" + idReadable
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+43
View File
@@ -0,0 +1,43 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>task-master</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

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