Compare commits
33
Commits
6527d91b4f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846bb31dc9 | ||
|
|
2dbf0918de | ||
|
|
98fcb57298 | ||
|
|
229f7a2fe1 | ||
|
|
69c6747a31 | ||
|
|
efeb4a74a8 | ||
|
|
26a3723a06 | ||
|
|
6e6b13270a | ||
|
|
614cb94b46 | ||
|
|
f97856b8a6 | ||
|
|
34ab3b9c5b | ||
|
|
2061df63d4 | ||
|
|
a594183310 | ||
|
|
b311a355fd | ||
|
|
79b0821837 | ||
|
|
afbf86dcf2 | ||
|
|
925bedf259 | ||
|
|
472a2fa72e | ||
|
|
74066649e4 | ||
|
|
4adfcdb95a | ||
|
|
98d2253f44 | ||
|
|
19ae1f1b62 | ||
|
|
214ae1e0cf | ||
|
|
81f2902ff1 | ||
|
|
f08084d5e1 | ||
|
|
7666715f6c | ||
|
|
c5920ffd54 | ||
|
|
f3a592f3ea | ||
|
|
2dcab9e4d8 | ||
|
|
21a0d2ad51 | ||
|
|
bfc1cbd4d9 | ||
|
|
41feba11de | ||
|
|
aa8f89b43a |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tm": {
|
||||
"command": "${workspaceFolder}/bin/tm",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: golang:latest
|
||||
options: --network host
|
||||
steps:
|
||||
# Node-Actions (checkout / upload-artifact) brauchen node im Container
|
||||
- name: Install Node.js
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends nodejs npm
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: List files in the repository
|
||||
run: ls ${{ gitea.workspace }}
|
||||
|
||||
- name: Download modules
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Build server
|
||||
run: make build-server
|
||||
|
||||
- name: Version
|
||||
run: make version
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tm
|
||||
path: |
|
||||
./bin/tm
|
||||
./bin/tm_server
|
||||
|
||||
- run: echo "This job's status is ${{ job.status }}."
|
||||
|
||||
demo:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-and-build
|
||||
container:
|
||||
image: ghcr.io/charmbracelet/vhs:latest
|
||||
options: --network host
|
||||
steps:
|
||||
- name: Install Node.js and vim
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends nodejs npm vim
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download binary
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: tm
|
||||
path: ./bin
|
||||
|
||||
- name: Make binary executable
|
||||
run: chmod +x ./bin/tm
|
||||
|
||||
- name: Record demo
|
||||
run: vhs demo.tape
|
||||
|
||||
- name: Upload demo
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: demo
|
||||
path: |
|
||||
demo.gif
|
||||
demo.mp4
|
||||
+15
@@ -1,4 +1,19 @@
|
||||
/bin/
|
||||
.dev-home/
|
||||
.tm_token
|
||||
**/.tm_token
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
# Generated by `make demo` / CI — commit demo.gif so the README preview works on Gitea
|
||||
demo.mp4
|
||||
demo.webm
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
# Vite output embedded via go:embed — rebuild with `make web` / `make build-server`
|
||||
internal/webembed/dist/**
|
||||
!internal/webembed/dist/.gitkeep
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
@@ -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).
|
||||
@@ -1,16 +1,78 @@
|
||||
BINARY := task-master
|
||||
CMD := ./cmd/task-master
|
||||
VERSION_PKG := task/internal/version
|
||||
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
||||
BINARY := tm
|
||||
BINARY_SERVER := tm_server
|
||||
CMD := ./cmd/task-master
|
||||
CMD_SERVER := ./cmd/tm-server
|
||||
VERSION_PKG := task/internal/version
|
||||
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
||||
|
||||
.PHONY: build run version
|
||||
# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=...
|
||||
GOPATH ?= $(shell go env GOPATH)
|
||||
PREFIX ?= $(GOPATH)
|
||||
INSTALL_BIN := $(PREFIX)/bin
|
||||
DEV_HOME := $(CURDIR)/.dev-home
|
||||
|
||||
.PHONY: build build-server build-all web web-build run run-dev run-server-dev install uninstall test version demo
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
|
||||
|
||||
web:
|
||||
cd web && npm ci && npm run build
|
||||
@mkdir -p internal/webembed/dist && touch internal/webembed/dist/.gitkeep
|
||||
|
||||
# Rebuild frontend only (assumes web/node_modules already installed).
|
||||
web-build:
|
||||
cd web && npm run build
|
||||
@mkdir -p internal/webembed/dist && touch internal/webembed/dist/.gitkeep
|
||||
|
||||
build-server: web
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
|
||||
|
||||
# Build server binary without rebuilding frontend (uses local webembed/dist).
|
||||
build-server-go:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
|
||||
|
||||
build-all: build build-server
|
||||
|
||||
run: build
|
||||
./bin/$(BINARY)
|
||||
|
||||
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config).
|
||||
# YouTrack is not part of the local test environment — use tm_server instead.
|
||||
run-dev: build
|
||||
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
|
||||
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
|
||||
|
||||
# Web test environment: SQLite demo against tm_server only (no YouTrack).
|
||||
# Seeds 50 demo projects; wires TUI projects Test + Website Relaunch with write tokens.
|
||||
# Login admin/admin (alice/alice as member). Then: make run-dev → Y pull.
|
||||
run-server-dev: web-build build-server-go
|
||||
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
|
||||
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \
|
||||
$(DEV_HOME)/.local/share/task-master/task-master.db-shm
|
||||
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack \
|
||||
"$(DEV_HOME)/.local/share/task-master/projects/Test" \
|
||||
"$(DEV_HOME)/.local/share/task-master/projects/Website Relaunch"
|
||||
TASK_MASTER_HOME=$(DEV_HOME) TM_ADMIN_USER=admin TM_ADMIN_PASSWORD=admin \
|
||||
TM_SEED_DEMO=1 TASK_MASTER_SECRET=dev-secret \
|
||||
./bin/$(BINARY_SERVER) -listen :8080
|
||||
|
||||
install: build
|
||||
install -d $(INSTALL_BIN)
|
||||
install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY)
|
||||
@echo "installed $(INSTALL_BIN)/$(BINARY)"
|
||||
|
||||
uninstall:
|
||||
rm -f $(INSTALL_BIN)/$(BINARY)
|
||||
@echo "removed $(INSTALL_BIN)/$(BINARY)"
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
version: build
|
||||
./bin/$(BINARY) version
|
||||
|
||||
# Record a full feature walkthrough (requires vhs: https://github.com/charmbracelet/vhs)
|
||||
demo:
|
||||
vhs demo.tape
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# task-master
|
||||
|
||||
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea). Dazu der optionale Web-Server `tm_server` mit Svelte-UI und SQLite.
|
||||
|
||||

|
||||
|
||||
> Demo wird aus [`demo.tape`](demo.tape) erzeugt (`make demo`). Ohne GIF lokal: siehe [Demo aufnehmen](#demo-aufnehmen).
|
||||
|
||||
## Features
|
||||
|
||||
**TUI (`tm`)**
|
||||
|
||||
- Projekte & Tickets lokal als JSON hinter einem Hintergrund-Daemon (Unix-Socket)
|
||||
- Mehrere TUI-Instanzen teilen denselben State; `tm stop` beendet den Daemon
|
||||
- Board-Filter: All / Backlog / In Progress / Done
|
||||
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
|
||||
- Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren
|
||||
- Report mit Group-by (Ticket / Tag)
|
||||
- Undo / Redo über eine Command-History (im Daemon)
|
||||
- Vim-Style Keybindings
|
||||
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
|
||||
- Sync gegen `tm_server` (Backend `taskmaster`, Projekt-Tokens)
|
||||
- MCP-Token pro Projekt erzeugen (`g` in der Projektliste) für späteren KI-Zugriff
|
||||
|
||||
**Web (`tm_server`)**
|
||||
|
||||
- Eingebettetes Svelte-UI, SQLite, Auth und Rechte pro Projekt
|
||||
- Projektgruppen, Sidebar-Navigation, Hash-Routes
|
||||
- Drag & Drop auf dem Board (OPEN / WIP / DONE)
|
||||
- Lesbare Ticket-Keys aus Projektcode + Sequenz (z. B. `WEB-12`)
|
||||
- Visual- und Markdown-Editor für Ticket-Beschreibungen
|
||||
- Datei-Anhänge; Tabellen (XLSX/ODS/…) im Browser editierbar
|
||||
- Report mit Datums- und Ticket-Filtern sowie Eintragsliste
|
||||
- Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
|
||||
- Members, Settings (Tokens), Users (Admin)
|
||||
|
||||
## Install
|
||||
|
||||
Voraussetzung: Go (siehe `go.mod`). Für `tm_server` zusätzlich Node.js (Frontend-Build).
|
||||
|
||||
```bash
|
||||
git clone git@git.aloxdrim.de:skruecken/task-master.git
|
||||
cd task-master
|
||||
make install # → $(go env GOPATH)/bin/tm
|
||||
# oder nur bauen:
|
||||
make build # → ./bin/tm
|
||||
make build-server # Frontend + → ./bin/tm_server
|
||||
make build-all # beides
|
||||
```
|
||||
|
||||
## Nutzung
|
||||
|
||||
```bash
|
||||
tm # TUI starten (startet Daemon bei Bedarf)
|
||||
tm sync pull # Issues von YouTrack / tm_server holen
|
||||
tm sync push # lokale Änderungen pushen
|
||||
tm sync pull -p inbox # anderes Projekt
|
||||
tm status # Daemon-Status + Socket-Pfad
|
||||
tm stop # Hintergrund-Daemon beenden
|
||||
tm daemon # Daemon im Vordergrund (Debug)
|
||||
tm version # Build-Version (Git-Hash)
|
||||
tm help
|
||||
```
|
||||
|
||||
### Lokaler JSON-Daemon
|
||||
|
||||
`tm` speichert weiterhin JSON unter dem DataDir, schreibt aber nur noch über einen lokalen Daemon-Prozess (eine Instanz pro DataDir). Socket/PID liegen unter `$XDG_RUNTIME_DIR/task-master-<hash>/` (Fallback: Temp-Dir). Die TUI und `tm sync` verbinden sich als vertrauenswürdige Clients (`X-TM-Client: tui|cli`).
|
||||
|
||||
### MCP (`tm mcp`)
|
||||
|
||||
1. In der TUI-Projektliste `g` → Token erzeugen (Klartext einmal in der Statuszeile); `G` widerruft.
|
||||
2. Im Repo-Root (oder darüber) `.tm_token` anlegen — **nicht committen**:
|
||||
|
||||
```
|
||||
project=inbox
|
||||
token=tm_mcp_…
|
||||
```
|
||||
|
||||
3. Cursor / MCP-Client:
|
||||
|
||||
```json
|
||||
{ "command": "tm", "args": ["mcp"] }
|
||||
```
|
||||
|
||||
Ohne gültiges `.tm_token` macht der MCP-Server keine Daemon-Aufrufe. Mit Token sind Lesen und Schreiben nur für dieses eine TM-Projekt erlaubt.
|
||||
|
||||
### Web-Server (`tm_server`)
|
||||
|
||||
```bash
|
||||
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekte + verdrahtet TUI
|
||||
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
|
||||
make run-dev # TUI: Projekte „Test“ + „Website Relaunch“ schon mit Token connected
|
||||
|
||||
# Produktion / manuell:
|
||||
export TM_ADMIN_USER=admin
|
||||
export TM_ADMIN_PASSWORD='change-me'
|
||||
export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
|
||||
./bin/tm_server -listen :8080
|
||||
|
||||
# JSON-Daten der TUI einmalig importieren:
|
||||
./bin/tm_server import-json -owner admin
|
||||
```
|
||||
|
||||
UI: http://localhost:8080 — Dashboard, Board, Report, Attachments, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
|
||||
|
||||
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
|
||||
Beim Seed (`TM_SEED_DEMO=1`) werden Demo-Projekte angelegt und für **Test** sowie **Website Relaunch** Write-Tokens unter `.dev-home` als lokale TUI-Projekte (`backend=taskmaster`) hinterlegt — danach `make run-dev` und Sync (`Y` → Pull). Login: `admin`/`admin` (Member: `alice`/`alice`).
|
||||
|
||||
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`. Frontend neu bauen ohne Go-Rebuild: `make web-build`.
|
||||
|
||||
Sandbox ohne echte Config/Daten:
|
||||
|
||||
```bash
|
||||
make run-dev # nutzt ./.dev-home
|
||||
# oder:
|
||||
TASK_MASTER_HOME=/tmp/tm-sandbox tm
|
||||
```
|
||||
|
||||
Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~/.config/task-master`.
|
||||
|
||||
| Variable | Wirkung |
|
||||
|------------------------|---------------------------------------------------|
|
||||
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
|
||||
| `TASK_MASTER_DATA_DIR` | Nur Datenverzeichnis überschreiben |
|
||||
| `TASK_MASTER_SECRET` | Verschlüsselt Remote-Tokens in SQLite |
|
||||
| `TM_ADMIN_USER` / `TM_ADMIN_PASSWORD` | Bootstrap des ersten Admins |
|
||||
| `TM_SEED_DEMO=1` | Demo-Projekte + TUI-Verdrahtung (Test / Website Relaunch) |
|
||||
| `TM_SEED_BASE_URL` | Basis-URL in den geseedeten TUI-`meta.json` |
|
||||
|
||||
Server-Flags: `-listen`, `-db`, `-secure` (siehe `tm_server help`).
|
||||
|
||||
## Keybindings (Auszug)
|
||||
|
||||
| Taste | Aktion |
|
||||
|---------|------------------------|
|
||||
| `j`/`k` | Navigieren |
|
||||
| `n` | Neues Ticket/Projekt |
|
||||
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack / Sync-Backend) |
|
||||
| `s` | Status ändern |
|
||||
| `l` | Board-Filter |
|
||||
| `/` | Suche |
|
||||
| `p`/`P` | Projekt / Ticket verschieben |
|
||||
| `Y` | Sync-Menü: Pull / Push |
|
||||
| `t`/`T` | Timer / Zeit buchen |
|
||||
| `m` | Zeiteinträge |
|
||||
| `r` | Report |
|
||||
| `x`/`X` | Löschen / Force-Delete |
|
||||
| `u`/`U` | Undo / Redo |
|
||||
| `q` | Beenden |
|
||||
|
||||
Checklisten im Preview: `J`/`K` + `Space`.
|
||||
|
||||
### YouTrack / Sync
|
||||
|
||||
1. `p` → Projekt wählen → `e`
|
||||
2. Backend wählen: YouTrack (Remote-URL, Permanent Token, Project-Key) oder task-master (Server-URL + Projekt-Token)
|
||||
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
|
||||
4. `Y` → **Pull** oder **Push**
|
||||
CLI: `tm sync pull` / `tm sync push`
|
||||
|
||||
Zeit: lokale Einträge ohne `externalId` werden remote als Work-Items gepusht; remote Work Items landen lokal beim Pull.
|
||||
|
||||
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
make test
|
||||
make build
|
||||
make build-server # braucht Node (npm ci + Vite-Build)
|
||||
make version
|
||||
```
|
||||
|
||||
CI (Gitea): Tests, `tm` + `tm_server`-Build, Demo-GIF als Artifact.
|
||||
|
||||
## Demo aufnehmen
|
||||
|
||||
Die Walkthrough-Aufnahme steckt in [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)):
|
||||
|
||||
```bash
|
||||
# einmalig: vhs + ttyd/ffmpeg (siehe VHS-Docs)
|
||||
make demo # schreibt demo.gif (+ demo.mp4)
|
||||
```
|
||||
|
||||
Die Pipeline erzeugt das GIF ebenfalls und lädt es als Artifact hoch.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Privat / eigener Use — siehe Repository-Einstellungen auf Gitea.
|
||||
+213
-18
@@ -1,15 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/config"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/daemon"
|
||||
"task/internal/daemon/api"
|
||||
"task/internal/daemon/client"
|
||||
mcpserver "task/internal/mcp"
|
||||
"task/internal/ui"
|
||||
"task/internal/version"
|
||||
)
|
||||
@@ -18,11 +21,41 @@ func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "version", "--version", "-v":
|
||||
fmt.Printf("%s %s\n", config.AppName, version.BuildVersion)
|
||||
fmt.Printf("%s %s\n", config.CliName, version.BuildVersion)
|
||||
return
|
||||
case "help", "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
case "sync":
|
||||
if err := runSync(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "daemon":
|
||||
if err := runDaemon(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "stop":
|
||||
if err := runStop(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "status":
|
||||
if err := runStatus(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "mcp":
|
||||
if err := runMCP(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
printHelp()
|
||||
@@ -36,37 +69,199 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
func loadConfig() (*config.Config, error) {
|
||||
cfg, err := config.LoadOrCreate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
abs, err := daemon.AbsoluteDataDir(cfg.DataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.DataDir = abs
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
store := ticket.NewJSONStore(cfg.DataDir)
|
||||
projects := project.NewDirStore(cfg.DataDir)
|
||||
if err := projects.Ensure(cfg.ActiveProject); err != nil {
|
||||
func ensureClient(kind string) (*config.Config, *client.Client, error) {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
paths, err := daemon.Ensure(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("daemon: %w", err)
|
||||
}
|
||||
cli := client.Dial(client.Options{Socket: paths.Socket, Kind: kind})
|
||||
return cfg, cli, nil
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, cli, err := ensureClient(api.ClientTUI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cli.ProjectStore().Ensure(cfg.ActiveProject); err != nil {
|
||||
return fmt.Errorf("ensure project: %w", err)
|
||||
}
|
||||
|
||||
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: %w", err)
|
||||
}
|
||||
model := ui.New(cfg, store, projects, history)
|
||||
model := ui.New(cfg, cli.TicketStore(), cli.ProjectStore(), cli.TimeStore(), cli.Executor(), cli)
|
||||
prog := tea.NewProgram(model, tea.WithAltScreen())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
_ = cli.SubscribeEvents(ctx, func(ev api.Event) {
|
||||
prog.Send(ui.DaemonChangedMsg{ProjectID: ev.ProjectID})
|
||||
})
|
||||
}()
|
||||
|
||||
if _, err := prog.Run(); err != nil {
|
||||
return fmt.Errorf("tui: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDaemon() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.Run(cfg)
|
||||
}
|
||||
|
||||
func runStop() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := daemon.Stop(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("daemon stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStatus() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := daemon.StatusOf(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := "stopped"
|
||||
if st.Running {
|
||||
state = "running"
|
||||
}
|
||||
fmt.Printf("status: %s\n", state)
|
||||
fmt.Printf("socket: %s\n", st.Socket)
|
||||
fmt.Printf("dataDir: %s\n", st.DataDir)
|
||||
if st.PID > 0 {
|
||||
fmt.Printf("pid: %d\n", st.PID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSync(args []string) error {
|
||||
cfg, cli, err := ensureClient(api.ClientCLI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID := cfg.ActiveProject
|
||||
action := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "pull", "push":
|
||||
if action != "" {
|
||||
return fmt.Errorf("specify only one of pull/push")
|
||||
}
|
||||
action = args[i]
|
||||
case "--project", "-p":
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("%s requires a project id", args[i])
|
||||
}
|
||||
i++
|
||||
projectID = strings.TrimSpace(args[i])
|
||||
case "--help", "-h":
|
||||
fmt.Printf("Usage: %s sync <pull|push> [--project ID]\n", config.CliName)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown sync argument: %s (use pull or push)", args[i])
|
||||
}
|
||||
}
|
||||
if action == "" {
|
||||
return fmt.Errorf("usage: %s sync <pull|push> [--project ID]", config.CliName)
|
||||
}
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("project id required")
|
||||
}
|
||||
|
||||
if action == "push" {
|
||||
out, err := cli.SyncPush(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
|
||||
projectID, out.Created, out.Updated, out.TimePushed)
|
||||
return nil
|
||||
}
|
||||
out, err := cli.SyncPull(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
|
||||
projectID, out.Created, out.Updated, out.TimePulled, out.Total)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMCP() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tf, err := mcpserver.FindTokenFile(cwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths, err := daemon.Ensure(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: %w", err)
|
||||
}
|
||||
cli := client.Dial(client.Options{
|
||||
Socket: paths.Socket,
|
||||
Kind: api.ClientMCP,
|
||||
Token: tf.Token,
|
||||
Project: tf.Project,
|
||||
})
|
||||
return mcpserver.ServeStdio(cli, tf)
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Printf(`%s — offline-first terminal task manager
|
||||
|
||||
Usage:
|
||||
%s Start the TUI
|
||||
%s version Print build version
|
||||
%s help Show this help
|
||||
%s Start the TUI (starts local daemon if needed)
|
||||
%s sync pull [-p ID] Pull via daemon from configured backend
|
||||
%s sync push [-p ID] Push via daemon to configured backend
|
||||
%s daemon Run the JSON daemon in the foreground
|
||||
%s stop Stop the background daemon
|
||||
%s status Show daemon status and socket path
|
||||
%s mcp MCP stdio server (requires .tm_token)
|
||||
%s version Print build version
|
||||
%s help Show this help
|
||||
|
||||
`, config.AppName, config.AppName, config.AppName, config.AppName)
|
||||
Env:
|
||||
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
|
||||
TASK_MASTER_DATA_DIR Override data directory only
|
||||
|
||||
The TUI talks to a per-DataDir background daemon over a Unix socket so multiple
|
||||
instances stay consistent. Create an MCP token from the project list (key g);
|
||||
place project= and token= in a repo .tm_token file, then run: tm mcp
|
||||
|
||||
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
# task-master feature demo
|
||||
# Run from repo root: make demo (or: vhs demo.tape)
|
||||
|
||||
Output demo.gif
|
||||
Output demo.mp4
|
||||
|
||||
Require go
|
||||
Require vim
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 1280
|
||||
Set Height 820
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set TypingSpeed 35ms
|
||||
Set PlaybackSpeed 1.0
|
||||
Set Padding 12
|
||||
Set Framerate 30
|
||||
|
||||
# Isolated config/data so the demo never touches your real ~/.config
|
||||
Env TASK_MASTER_HOME "/tmp/task-master-vhs"
|
||||
Env TERM "xterm-256color"
|
||||
Env EDITOR "vim"
|
||||
|
||||
# --- setup (hidden) ----------------------------------------------------------
|
||||
Hide
|
||||
Type "rm -rf /tmp/task-master-vhs && mkdir -p /tmp/task-master-vhs"
|
||||
Enter
|
||||
Sleep 300ms
|
||||
Type "test -x ./bin/tm || make build"
|
||||
Enter
|
||||
Sleep 5s
|
||||
Show
|
||||
|
||||
# --- launch ------------------------------------------------------------------
|
||||
Type "./bin/tm"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
# --- create tickets ----------------------------------------------------------
|
||||
Type "n"
|
||||
Sleep 400ms
|
||||
Type "Design landing page"
|
||||
Enter
|
||||
Sleep 800ms
|
||||
|
||||
Type "n"
|
||||
Sleep 400ms
|
||||
Type "Wire up auth"
|
||||
Enter
|
||||
Sleep 800ms
|
||||
|
||||
Type "n"
|
||||
Sleep 400ms
|
||||
Type "Ship MVP checklist"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
# --- navigate to first ticket ------------------------------------------------
|
||||
Type "k"
|
||||
Sleep 400ms
|
||||
Type "k"
|
||||
Sleep 800ms
|
||||
|
||||
# --- edit ticket in vim (markdown + checklist) -------------------------------
|
||||
Type "e"
|
||||
Sleep 1s
|
||||
# File opens as "# Design landing page" — append a markdown description
|
||||
Type "Go"
|
||||
Sleep 300ms
|
||||
Enter
|
||||
Type "## Goals"
|
||||
Enter
|
||||
Type "- [ ] Hero section with brand"
|
||||
Enter
|
||||
Type "- [ ] Primary CTA above the fold"
|
||||
Enter
|
||||
Type "- [ ] Keep the copy **short**"
|
||||
Sleep 600ms
|
||||
Escape
|
||||
Sleep 400ms
|
||||
Type ":wq"
|
||||
Enter
|
||||
Sleep 1.5s
|
||||
# Expanded card shows checkboxes in the preview
|
||||
Sleep 1.5s
|
||||
|
||||
# --- toggle checkboxes in the ticket preview ---------------------------------
|
||||
# J/K (shift) move the checklist cursor; Space toggles — j/k still move tickets
|
||||
Type "J"
|
||||
Sleep 700ms
|
||||
Space
|
||||
Sleep 1s
|
||||
Type "J"
|
||||
Sleep 700ms
|
||||
Space
|
||||
Sleep 1s
|
||||
Type "K"
|
||||
Sleep 700ms
|
||||
Space
|
||||
Sleep 1.2s
|
||||
|
||||
# --- status picker (letter jump) ---------------------------------------------
|
||||
Type "s"
|
||||
Sleep 800ms
|
||||
Type "w"
|
||||
Sleep 500ms
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
# peek another ticket, then back — checklist state stays on the edited card
|
||||
Type "j"
|
||||
Sleep 700ms
|
||||
Type "k"
|
||||
Sleep 1s
|
||||
|
||||
# --- board filter (multiselect) ----------------------------------------------
|
||||
Type "l"
|
||||
Sleep 800ms
|
||||
Type "b"
|
||||
Sleep 300ms
|
||||
Space
|
||||
Sleep 400ms
|
||||
Type "i"
|
||||
Sleep 300ms
|
||||
Space
|
||||
Sleep 400ms
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
# show all again
|
||||
Type "l"
|
||||
Sleep 600ms
|
||||
Type "a"
|
||||
Sleep 300ms
|
||||
Enter
|
||||
Sleep 800ms
|
||||
|
||||
# --- search / filter ---------------------------------------------------------
|
||||
Type "/"
|
||||
Sleep 400ms
|
||||
Type "auth"
|
||||
Enter
|
||||
Sleep 1s
|
||||
Type "/"
|
||||
Sleep 300ms
|
||||
Enter
|
||||
Sleep 800ms
|
||||
|
||||
# --- projects: create + switch -----------------------------------------------
|
||||
Type "p"
|
||||
Sleep 800ms
|
||||
Type "n"
|
||||
Sleep 400ms
|
||||
Type "client-acme"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
Type "n"
|
||||
Sleep 400ms
|
||||
Type "Kickoff call notes"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
Type "p"
|
||||
Sleep 800ms
|
||||
Type "i"
|
||||
Sleep 400ms
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
# --- timer -------------------------------------------------------------------
|
||||
Type "j"
|
||||
Sleep 300ms
|
||||
Type "t"
|
||||
Sleep 1.5s
|
||||
Type "t"
|
||||
Sleep 1s
|
||||
|
||||
# --- manual time log ---------------------------------------------------------
|
||||
Type "T"
|
||||
Sleep 500ms
|
||||
Type "45m"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
# --- manage time entries (edit duration) -------------------------------------
|
||||
Type "m"
|
||||
Sleep 1s
|
||||
Type "e"
|
||||
Sleep 500ms
|
||||
Backspace 3
|
||||
Type "1h15m"
|
||||
Enter
|
||||
Sleep 1s
|
||||
Escape
|
||||
Sleep 800ms
|
||||
|
||||
# --- report + group-by picker ------------------------------------------------
|
||||
Type "r"
|
||||
Sleep 1.2s
|
||||
Type "g"
|
||||
Sleep 800ms
|
||||
Type "t"
|
||||
Sleep 400ms
|
||||
Enter
|
||||
Sleep 1.2s
|
||||
Type "g"
|
||||
Sleep 600ms
|
||||
Type "d"
|
||||
Sleep 400ms
|
||||
Enter
|
||||
Sleep 1.2s
|
||||
Escape
|
||||
Sleep 800ms
|
||||
|
||||
# --- undo / redo -------------------------------------------------------------
|
||||
Type "u"
|
||||
Sleep 1s
|
||||
Type "U"
|
||||
Sleep 1s
|
||||
|
||||
# --- delete with confirm -----------------------------------------------------
|
||||
Type "n"
|
||||
Sleep 400ms
|
||||
Type "throwaway ticket"
|
||||
Enter
|
||||
Sleep 800ms
|
||||
Type "x"
|
||||
Sleep 700ms
|
||||
Type "y"
|
||||
Sleep 1s
|
||||
|
||||
# --- force delete ------------------------------------------------------------
|
||||
Type "n"
|
||||
Sleep 400ms
|
||||
Type "another draft"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type "X"
|
||||
Sleep 1s
|
||||
|
||||
# --- done --------------------------------------------------------------------
|
||||
Sleep 1.5s
|
||||
Type "q"
|
||||
Sleep 500ms
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -21,12 +21,13 @@ type Executor interface {
|
||||
|
||||
// History is an undo/redo stack for Command values, optionally persisted to disk.
|
||||
type History struct {
|
||||
mu sync.Mutex
|
||||
undoS []Command
|
||||
redoS []Command
|
||||
path string
|
||||
store ticket.Store
|
||||
max int
|
||||
mu sync.Mutex
|
||||
undoS []Command
|
||||
redoS []Command
|
||||
path string
|
||||
store ticket.Store
|
||||
timeStore ticket.TimeStore
|
||||
max int
|
||||
}
|
||||
|
||||
// NewHistory creates an empty in-memory command history (cap MaxHistory).
|
||||
@@ -36,13 +37,15 @@ func NewHistory() *History {
|
||||
|
||||
// OpenHistory loads a persisted history from path (DataDir/history.json) or
|
||||
// starts empty if the file is missing. Subsequent Do/Undo/Redo write back.
|
||||
func OpenHistory(path string, store ticket.Store) (*History, error) {
|
||||
// timeStore may be nil if time-entry commands are unused.
|
||||
func OpenHistory(path string, store ticket.Store, timeStore ticket.TimeStore) (*History, error) {
|
||||
h := &History{
|
||||
path: path,
|
||||
store: store,
|
||||
max: MaxHistory,
|
||||
path: path,
|
||||
store: store,
|
||||
timeStore: timeStore,
|
||||
max: MaxHistory,
|
||||
}
|
||||
undo, redo, err := loadHistoryFile(path, store)
|
||||
undo, redo, err := loadHistoryFile(path, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package command_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestMoveTicketUndo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
timeStore := ticket.NewJSONTimeStore(dir)
|
||||
h := command.NewHistory()
|
||||
|
||||
created, err := store.Create(ticket.Ticket{
|
||||
Title: "Misfiled", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := timeStore.Create(ticket.TimeEntry{
|
||||
TicketID: created.ID, ProjectID: "inbox", Duration: 15 * time.Minute, Source: ticket.SourceManual,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after := created
|
||||
after.ProjectID = "client"
|
||||
if err := h.Do(&command.MoveTicket{
|
||||
Store: store, TimeStore: timeStore, Before: created, After: after,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
list, _ := store.List("client")
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("client len=%d", len(list))
|
||||
}
|
||||
entries, _ := timeStore.ListByTicket(created.ID)
|
||||
if len(entries) != 1 || entries[0].ProjectID != "client" {
|
||||
t.Fatalf("time entries: %+v", entries)
|
||||
}
|
||||
|
||||
if err := h.Undo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = store.List("inbox")
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("inbox after undo len=%d", len(list))
|
||||
}
|
||||
entries, _ = timeStore.ListByTicket(created.ID)
|
||||
if len(entries) != 1 || entries[0].ProjectID != "inbox" {
|
||||
t.Fatalf("time after undo: %+v", entries)
|
||||
}
|
||||
}
|
||||
+66
-14
@@ -21,17 +21,24 @@ type persistedFile struct {
|
||||
}
|
||||
|
||||
type persistedCmd struct {
|
||||
Type string `json:"type"`
|
||||
Ticket *ticket.Ticket `json:"ticket,omitempty"`
|
||||
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
|
||||
Before *ticket.Ticket `json:"before,omitempty"`
|
||||
After *ticket.Ticket `json:"after,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Ticket *ticket.Ticket `json:"ticket,omitempty"`
|
||||
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
|
||||
Before *ticket.Ticket `json:"before,omitempty"`
|
||||
After *ticket.Ticket `json:"after,omitempty"`
|
||||
TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"`
|
||||
TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"`
|
||||
TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
typeCreate = "create"
|
||||
typeDelete = "delete"
|
||||
typeUpdate = "update"
|
||||
typeCreate = "create"
|
||||
typeDelete = "delete"
|
||||
typeUpdate = "update"
|
||||
typeAddTime = "add_time"
|
||||
typeRemoveTime = "remove_time"
|
||||
typeUpdateTime = "update_time"
|
||||
typeMove = "move"
|
||||
)
|
||||
|
||||
// HistoryPath returns DataDir/history.json.
|
||||
@@ -54,12 +61,28 @@ func encodeCmd(cmd Command) (persistedCmd, error) {
|
||||
case *UpdateTicket:
|
||||
b, a := c.Before, c.After
|
||||
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
|
||||
case *AddTimeEntry:
|
||||
e := c.Entry
|
||||
if c.created.ID != "" {
|
||||
e = c.created
|
||||
}
|
||||
cp := e
|
||||
return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil
|
||||
case *RemoveTimeEntry:
|
||||
cp := c.Snapshot
|
||||
return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil
|
||||
case *UpdateTimeEntry:
|
||||
b, a := c.Before, c.After
|
||||
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
|
||||
case *MoveTicket:
|
||||
b, a := c.Before, c.After
|
||||
return persistedCmd{Type: typeMove, Before: &b, After: &a}, nil
|
||||
default:
|
||||
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
|
||||
func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) {
|
||||
switch rec.Type {
|
||||
case typeCreate:
|
||||
if rec.Ticket == nil {
|
||||
@@ -76,6 +99,35 @@ func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
|
||||
return nil, fmt.Errorf("update record missing before/after")
|
||||
}
|
||||
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
|
||||
case typeAddTime:
|
||||
if rec.TimeEntry == nil {
|
||||
return nil, fmt.Errorf("add_time record missing entry")
|
||||
}
|
||||
if timeStore == nil {
|
||||
return nil, fmt.Errorf("add_time: time store not configured")
|
||||
}
|
||||
return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil
|
||||
case typeRemoveTime:
|
||||
if rec.TimeEntry == nil {
|
||||
return nil, fmt.Errorf("remove_time record missing entry")
|
||||
}
|
||||
if timeStore == nil {
|
||||
return nil, fmt.Errorf("remove_time: time store not configured")
|
||||
}
|
||||
return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil
|
||||
case typeUpdateTime:
|
||||
if rec.TimeBefore == nil || rec.TimeAfter == nil {
|
||||
return nil, fmt.Errorf("update_time record missing before/after")
|
||||
}
|
||||
if timeStore == nil {
|
||||
return nil, fmt.Errorf("update_time: time store not configured")
|
||||
}
|
||||
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
|
||||
case typeMove:
|
||||
if rec.Before == nil || rec.After == nil {
|
||||
return nil, fmt.Errorf("move record missing before/after")
|
||||
}
|
||||
return &MoveTicket{Store: store, TimeStore: timeStore, Before: *rec.Before, After: *rec.After}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown command type %q", rec.Type)
|
||||
}
|
||||
@@ -93,10 +145,10 @@ func encodeStack(cmds []Command) ([]persistedCmd, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeStack(recs []persistedCmd, store ticket.Store) ([]Command, error) {
|
||||
func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) {
|
||||
out := make([]Command, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
cmd, err := decodeCmd(rec, store)
|
||||
cmd, err := decodeCmd(rec, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -154,7 +206,7 @@ func (h *History) persistLocked() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err error) {
|
||||
func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -169,11 +221,11 @@ func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse history: %w", err)
|
||||
}
|
||||
undo, err = decodeStack(file.Undo, store)
|
||||
undo, err = decodeStack(file.Undo, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
redo, err = decodeStack(file.Redo, store)
|
||||
redo, err = decodeStack(file.Redo, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
||||
store := ticket.NewJSONStore(dir)
|
||||
path := command.HistoryPath(dir)
|
||||
|
||||
h1, err := command.OpenHistory(path, store)
|
||||
h1, err := command.OpenHistory(path, store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
||||
t.Fatalf("history file missing: %v", err)
|
||||
}
|
||||
|
||||
h2, err := command.OpenHistory(path, store)
|
||||
h2, err := command.OpenHistory(path, store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
||||
func TestHistoryCapsAtMax(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store)
|
||||
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
// CreateTicket creates a ticket; Undo deletes it.
|
||||
type CreateTicket struct {
|
||||
Store ticket.Store
|
||||
Ticket ticket.Ticket
|
||||
Store ticket.Store
|
||||
Ticket ticket.Ticket
|
||||
created ticket.Ticket
|
||||
}
|
||||
|
||||
@@ -79,3 +79,43 @@ func (c *UpdateTicket) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return c.Store.Update(c.Before)
|
||||
}
|
||||
|
||||
// MoveTicket relocates a ticket to another project; Undo moves it back.
|
||||
type MoveTicket struct {
|
||||
Store ticket.Store
|
||||
TimeStore ticket.TimeStore
|
||||
Before ticket.Ticket
|
||||
After ticket.Ticket // ProjectID is the destination; other fields from Before
|
||||
}
|
||||
|
||||
func (c *MoveTicket) Name() string { return "move ticket" }
|
||||
|
||||
func (c *MoveTicket) Execute(ctx context.Context) error {
|
||||
_ = ctx
|
||||
moved, err := c.Store.Move(c.Before.ID, c.After.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.After = moved
|
||||
if c.TimeStore != nil {
|
||||
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MoveTicket) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
moved, err := c.Store.Move(c.After.ID, c.Before.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Before = moved
|
||||
if c.TimeStore != nil {
|
||||
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// AddTimeEntry creates a time entry; Undo deletes it.
|
||||
type AddTimeEntry struct {
|
||||
Store ticket.TimeStore
|
||||
Entry ticket.TimeEntry
|
||||
created ticket.TimeEntry
|
||||
}
|
||||
|
||||
func (c *AddTimeEntry) Name() string { return "add time entry" }
|
||||
|
||||
func (c *AddTimeEntry) Execute(ctx context.Context) error {
|
||||
_ = ctx
|
||||
created, err := c.Store.Create(c.Entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.created = created
|
||||
c.Entry = created
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AddTimeEntry) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
id := c.created.ID
|
||||
if id == "" {
|
||||
id = c.Entry.ID
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("add time undo: missing entry id")
|
||||
}
|
||||
return c.Store.Delete(id)
|
||||
}
|
||||
|
||||
// RemoveTimeEntry deletes a time entry; Undo restores the snapshot.
|
||||
type RemoveTimeEntry struct {
|
||||
Store ticket.TimeStore
|
||||
Snapshot ticket.TimeEntry
|
||||
}
|
||||
|
||||
func (c *RemoveTimeEntry) Name() string { return "remove time entry" }
|
||||
|
||||
func (c *RemoveTimeEntry) Execute(ctx context.Context) error {
|
||||
_ = ctx
|
||||
if c.Snapshot.ID == "" {
|
||||
return fmt.Errorf("remove time: missing entry id")
|
||||
}
|
||||
return c.Store.Delete(c.Snapshot.ID)
|
||||
}
|
||||
|
||||
func (c *RemoveTimeEntry) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
_, err := c.Store.Create(c.Snapshot)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTimeEntry applies a new entry state; Undo restores Before.
|
||||
type UpdateTimeEntry struct {
|
||||
Store ticket.TimeStore
|
||||
Before ticket.TimeEntry
|
||||
After ticket.TimeEntry
|
||||
}
|
||||
|
||||
func (c *UpdateTimeEntry) Name() string { return "update time entry" }
|
||||
|
||||
func (c *UpdateTimeEntry) Execute(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return c.Store.Update(c.After)
|
||||
}
|
||||
|
||||
func (c *UpdateTimeEntry) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return c.Store.Update(c.Before)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package command_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestUpdateTimeEntryUndo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONTimeStore(dir)
|
||||
h := command.NewHistory()
|
||||
|
||||
created, err := store.Create(ticket.TimeEntry{
|
||||
TicketID: "t1",
|
||||
ProjectID: "inbox",
|
||||
Start: time.Now().UTC().Add(-time.Hour),
|
||||
Duration: time.Hour,
|
||||
Source: ticket.SourceManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after := created
|
||||
after.Duration = 30 * time.Minute
|
||||
after.End = after.Start.Add(after.Duration)
|
||||
if err := h.Do(&command.UpdateTimeEntry{Store: store, Before: created, After: after}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := store.ListByTicket("t1")
|
||||
if len(list) != 1 || list[0].Duration != 30*time.Minute {
|
||||
t.Fatalf("after update: %+v", list)
|
||||
}
|
||||
if err := h.Undo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = store.ListByTicket("t1")
|
||||
if len(list) != 1 || list[0].Duration != time.Hour {
|
||||
t.Fatalf("after undo: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTimeEntryUndo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONTimeStore(dir)
|
||||
h := command.NewHistory()
|
||||
|
||||
created, err := store.Create(ticket.TimeEntry{
|
||||
TicketID: "t1",
|
||||
ProjectID: "inbox",
|
||||
Start: time.Now().UTC(),
|
||||
Duration: 15 * time.Minute,
|
||||
Source: ticket.SourceTimer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Do(&command.RemoveTimeEntry{Store: store, Snapshot: created}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := store.List()
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected empty after remove, got %d", len(list))
|
||||
}
|
||||
if err := h.Undo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = store.List()
|
||||
if len(list) != 1 || list[0].ID != created.ID {
|
||||
t.Fatalf("after undo: %+v", list)
|
||||
}
|
||||
}
|
||||
+28
-13
@@ -3,18 +3,25 @@ package config
|
||||
import "path/filepath"
|
||||
|
||||
const (
|
||||
AppName = "task-master"
|
||||
DefaultProject = "inbox"
|
||||
DefaultTheme = "default"
|
||||
configFileName = "config.json"
|
||||
// AppName is used for config/data directory names under XDG paths.
|
||||
AppName = "task-master"
|
||||
// CliName is the installed binary / command name.
|
||||
CliName = "tm"
|
||||
DefaultProject = "inbox"
|
||||
DefaultTheme = "default"
|
||||
configFileName = "config.json"
|
||||
|
||||
envHome = "TASK_MASTER_HOME"
|
||||
envDataDir = "TASK_MASTER_DATA_DIR"
|
||||
)
|
||||
|
||||
// Config holds application settings persisted under ~/.config/task-master.
|
||||
type Config struct {
|
||||
ActiveProject string `json:"activeProject"`
|
||||
DataDir string `json:"dataDir"`
|
||||
Theme string `json:"theme"`
|
||||
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done).
|
||||
ActiveProject string `json:"activeProject"`
|
||||
DataDir string `json:"dataDir"`
|
||||
Theme string `json:"theme"`
|
||||
// ProjectLists maps project ID → comma-separated board list ids
|
||||
// (e.g. "all" or "backlog,in_progress").
|
||||
ProjectLists map[string]string `json:"projectLists,omitempty"`
|
||||
}
|
||||
|
||||
@@ -55,8 +62,8 @@ func (c *Config) ApplyDefaults(home string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveBoardList returns the persisted board list for a project (default: all).
|
||||
func (c *Config) ActiveBoardList(projectID string) string {
|
||||
// ActiveBoardLists returns the persisted board filter string for a project (default: "all").
|
||||
func (c *Config) ActiveBoardLists(projectID string) string {
|
||||
if c.ProjectLists == nil {
|
||||
return "all"
|
||||
}
|
||||
@@ -66,10 +73,18 @@ func (c *Config) ActiveBoardList(projectID string) string {
|
||||
return "all"
|
||||
}
|
||||
|
||||
// SetActiveBoardList stores the board list for a project.
|
||||
func (c *Config) SetActiveBoardList(projectID, listID string) {
|
||||
// SetActiveBoardLists stores the board filter string for a project.
|
||||
func (c *Config) SetActiveBoardLists(projectID, listIDs string) {
|
||||
if c.ProjectLists == nil {
|
||||
c.ProjectLists = map[string]string{}
|
||||
}
|
||||
c.ProjectLists[projectID] = listID
|
||||
c.ProjectLists[projectID] = listIDs
|
||||
}
|
||||
|
||||
// ClearProjectLists removes the persisted board filter for a project.
|
||||
func (c *Config) ClearProjectLists(projectID string) {
|
||||
if c.ProjectLists == nil {
|
||||
return
|
||||
}
|
||||
delete(c.ProjectLists, projectID)
|
||||
}
|
||||
|
||||
@@ -5,14 +5,28 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveHome returns the effective home for config/data paths.
|
||||
// TASK_MASTER_HOME overrides the real user home (useful for demos/dev sandboxes).
|
||||
func ResolveHome() (string, error) {
|
||||
if h := strings.TrimSpace(os.Getenv(envHome)); h != "" {
|
||||
return filepath.Clean(h), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return home, nil
|
||||
}
|
||||
|
||||
// LoadOrCreate loads the config from disk, creating defaults if missing.
|
||||
// It also ensures DataDir and the active project directory exist.
|
||||
func LoadOrCreate() (*Config, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := ResolveHome()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfgPath := Path(home)
|
||||
@@ -26,6 +40,10 @@ func LoadOrCreate() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if d := strings.TrimSpace(os.Getenv(envDataDir)); d != "" {
|
||||
cfg.DataDir = filepath.Clean(d)
|
||||
}
|
||||
|
||||
if err := ensureDataDirs(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -54,11 +72,12 @@ func readOrCreate(cfgPath, home string) (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the config to ~/.config/task-master/config.json.
|
||||
// Save writes the config to ~/.config/task-master/config.json
|
||||
// (or under TASK_MASTER_HOME when set).
|
||||
func Save(cfg *Config) error {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := ResolveHome()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve home directory: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
cfgPath := Path(home)
|
||||
|
||||
@@ -30,6 +30,8 @@ func TestDefaultsAndApplyDefaults(t *testing.T) {
|
||||
func TestSaveRoundTrip(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("TASK_MASTER_HOME", "")
|
||||
t.Setenv("TASK_MASTER_DATA_DIR", "")
|
||||
|
||||
cfg := config.Defaults(home)
|
||||
cfg.ActiveProject = "work"
|
||||
@@ -54,6 +56,8 @@ func TestSaveRoundTrip(t *testing.T) {
|
||||
func TestLoadOrCreate(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("TASK_MASTER_HOME", "")
|
||||
t.Setenv("TASK_MASTER_DATA_DIR", "")
|
||||
|
||||
cfg, err := config.LoadOrCreate()
|
||||
if err != nil {
|
||||
@@ -81,3 +85,38 @@ func TestLoadOrCreate(t *testing.T) {
|
||||
t.Fatalf("DataDir mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTASK_MASTER_HOME(t *testing.T) {
|
||||
sandbox := t.TempDir()
|
||||
t.Setenv("TASK_MASTER_HOME", sandbox)
|
||||
t.Setenv("TASK_MASTER_DATA_DIR", "")
|
||||
|
||||
cfg, err := config.LoadOrCreate()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
wantCfg := config.Path(sandbox)
|
||||
if _, err := os.Stat(wantCfg); err != nil {
|
||||
t.Fatalf("config under sandbox missing: %v", err)
|
||||
}
|
||||
wantData := filepath.Join(sandbox, ".local", "share", config.AppName)
|
||||
if cfg.DataDir != wantData {
|
||||
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, wantData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTASK_MASTER_DATA_DIR(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
data := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("TASK_MASTER_HOME", "")
|
||||
t.Setenv("TASK_MASTER_DATA_DIR", data)
|
||||
|
||||
cfg, err := config.LoadOrCreate()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
if cfg.DataDir != data {
|
||||
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const metaFileName = "meta.json"
|
||||
|
||||
const (
|
||||
BackendNone = "none"
|
||||
BackendYouTrack = "youtrack"
|
||||
BackendTaskMaster = "taskmaster"
|
||||
)
|
||||
|
||||
// YouTrack holds remote sync settings for a project.
|
||||
type YouTrack struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Token string `json:"token"`
|
||||
ProjectKey string `json:"projectKey"`
|
||||
// Query overrides the default "project: <ProjectKey>" issue filter.
|
||||
Query string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
// Configured reports whether enough fields are set to sync.
|
||||
func (y YouTrack) Configured() bool {
|
||||
return strings.TrimSpace(y.BaseURL) != "" &&
|
||||
strings.TrimSpace(y.Token) != "" &&
|
||||
strings.TrimSpace(y.ProjectKey) != ""
|
||||
}
|
||||
|
||||
// IssueQuery returns the YouTrack search query used for sync.
|
||||
func (y YouTrack) IssueQuery() string {
|
||||
if q := strings.TrimSpace(y.Query); q != "" {
|
||||
return q
|
||||
}
|
||||
return "project: " + strings.TrimSpace(y.ProjectKey)
|
||||
}
|
||||
|
||||
// TaskMaster holds sync settings for a remote tm_server project.
|
||||
type TaskMaster struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Token string `json:"token"`
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
|
||||
// Configured reports whether task-master sync can run.
|
||||
func (t TaskMaster) Configured() bool {
|
||||
return strings.TrimSpace(t.BaseURL) != "" &&
|
||||
strings.TrimSpace(t.Token) != "" &&
|
||||
strings.TrimSpace(t.ProjectID) != ""
|
||||
}
|
||||
|
||||
// McpAuth holds the hashed local MCP token for a project (plaintext never stored).
|
||||
type McpAuth struct {
|
||||
TokenHash string `json:"tokenHash"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is per-project metadata stored beside tickets.json.
|
||||
type Meta struct {
|
||||
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
|
||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
|
||||
McpAuth *McpAuth `json:"mcpAuth,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
|
||||
func (m Meta) EffectiveBackend() string {
|
||||
b := strings.TrimSpace(strings.ToLower(m.Backend))
|
||||
switch b {
|
||||
case BackendYouTrack, BackendTaskMaster, BackendNone:
|
||||
return b
|
||||
}
|
||||
if m.YouTrack != nil && m.YouTrack.Configured() {
|
||||
return BackendYouTrack
|
||||
}
|
||||
if m.TaskMaster != nil && m.TaskMaster.Configured() {
|
||||
return BackendTaskMaster
|
||||
}
|
||||
if m.YouTrack != nil {
|
||||
return BackendYouTrack
|
||||
}
|
||||
if m.TaskMaster != nil {
|
||||
return BackendTaskMaster
|
||||
}
|
||||
return BackendNone
|
||||
}
|
||||
|
||||
// SyncConfigured reports whether the active backend is fully set up.
|
||||
func (m Meta) SyncConfigured() bool {
|
||||
switch m.EffectiveBackend() {
|
||||
case BackendYouTrack:
|
||||
return m.YouTrack != nil && m.YouTrack.Configured()
|
||||
case BackendTaskMaster:
|
||||
return m.TaskMaster != nil && m.TaskMaster.Configured()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DirStore) metaPath(id string) string {
|
||||
return filepath.Join(s.projectsRoot(), id, metaFileName)
|
||||
}
|
||||
|
||||
// LoadMeta reads projects/<id>/meta.json. Missing file yields empty Meta.
|
||||
func (s *DirStore) LoadMeta(id string) (Meta, error) {
|
||||
if id == "" {
|
||||
return Meta{}, fmt.Errorf("project id is required")
|
||||
}
|
||||
path := s.metaPath(id)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Meta{}, nil
|
||||
}
|
||||
return Meta{}, fmt.Errorf("read meta %s: %w", id, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Meta{}, nil
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
|
||||
}
|
||||
if meta.Backend == "" && meta.YouTrack != nil {
|
||||
meta.Backend = BackendYouTrack
|
||||
}
|
||||
if meta.Backend == "" && meta.TaskMaster != nil {
|
||||
meta.Backend = BackendTaskMaster
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// SaveMeta writes projects/<id>/meta.json with mode 0600 (may contain tokens).
|
||||
func (s *DirStore) SaveMeta(id string, meta Meta) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
if err := s.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.Backend == "" {
|
||||
meta.Backend = meta.EffectiveBackend()
|
||||
}
|
||||
path := s.metaPath(id)
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal meta: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "meta-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp meta: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("chmod temp meta: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp meta: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp meta: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename meta: %w", err)
|
||||
}
|
||||
_ = os.Chmod(path, 0o600)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package project_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
func TestMetaSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := project.NewDirStore(dir)
|
||||
if err := store.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta := project.Meta{
|
||||
YouTrack: &project.YouTrack{
|
||||
BaseURL: "https://yt.example.com",
|
||||
Token: "perm:secret",
|
||||
ProjectKey: "PROJ",
|
||||
},
|
||||
}
|
||||
if err := store.SaveMeta("inbox", meta); err != nil {
|
||||
t.Fatalf("SaveMeta: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "projects", "inbox", "meta.json")
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if st.Mode().Perm()&0o077 != 0 {
|
||||
t.Fatalf("meta permissions too open: %o", st.Mode().Perm())
|
||||
}
|
||||
|
||||
got, err := store.LoadMeta("inbox")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMeta: %v", err)
|
||||
}
|
||||
if got.YouTrack == nil || got.YouTrack.Token != "perm:secret" || got.YouTrack.ProjectKey != "PROJ" {
|
||||
t.Fatalf("got %+v", got.YouTrack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMetaMissing(t *testing.T) {
|
||||
store := project.NewDirStore(t.TempDir())
|
||||
got, err := store.LoadMeta("missing")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMeta: %v", err)
|
||||
}
|
||||
if got.YouTrack != nil {
|
||||
t.Fatalf("expected empty meta, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTrackConfiguredAndQuery(t *testing.T) {
|
||||
y := project.YouTrack{BaseURL: "https://x", Token: "t", ProjectKey: "ABC"}
|
||||
if !y.Configured() {
|
||||
t.Fatal("expected configured")
|
||||
}
|
||||
if y.IssueQuery() != "project: ABC" {
|
||||
t.Fatalf("query = %q", y.IssueQuery())
|
||||
}
|
||||
y.Query = "Assignee: me"
|
||||
if y.IssueQuery() != "Assignee: me" {
|
||||
t.Fatalf("query = %q", y.IssueQuery())
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
type Store interface {
|
||||
List() ([]Project, error)
|
||||
Ensure(id string) error
|
||||
Delete(id string) error
|
||||
LoadMeta(id string) (Meta, error)
|
||||
SaveMeta(id string, meta Meta) error
|
||||
}
|
||||
|
||||
// DirStore discovers projects as subdirectories of dataDir/projects.
|
||||
@@ -63,3 +66,25 @@ func (s *DirStore) Ensure(id string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the project directory and all of its files.
|
||||
func (s *DirStore) Delete(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
dir := filepath.Join(s.projectsRoot(), id)
|
||||
st, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
return fmt.Errorf("stat project %s: %w", id, err)
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return fmt.Errorf("project %s: not a directory", id)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("delete project %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package project_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
func TestDirStoreDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := project.NewDirStore(dir)
|
||||
|
||||
if err := store.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Ensure("client"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Drop a marker file so RemoveAll has something to wipe.
|
||||
marker := filepath.Join(dir, "projects", "client", "tickets.json")
|
||||
if err := os.WriteFile(marker, []byte("[]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
list, err := store.List()
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("list before: %v len=%d", err, len(list))
|
||||
}
|
||||
|
||||
if err := store.Delete("client"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = store.List()
|
||||
if err != nil || len(list) != 1 || list[0].ID != "inbox" {
|
||||
t.Fatalf("list after: %v %+v", err, list)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "projects", "client")); !os.IsNotExist(err) {
|
||||
t.Fatalf("client dir still present: %v", err)
|
||||
}
|
||||
|
||||
if err := store.Delete("missing"); err == nil {
|
||||
t.Fatal("expected error deleting missing project")
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// BoardList is a project-scoped view/filter over tickets by status.
|
||||
type BoardList string
|
||||
|
||||
@@ -29,6 +34,15 @@ func (b BoardList) Label() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcut returns the board-menu hotkey (first letter of the label).
|
||||
func (b BoardList) Shortcut() rune {
|
||||
label := b.Label()
|
||||
if label == "" {
|
||||
return 0
|
||||
}
|
||||
return unicode.ToLower([]rune(label)[0])
|
||||
}
|
||||
|
||||
// Index returns the position in AllBoardLists.
|
||||
func (b BoardList) Index() int {
|
||||
for i, bl := range AllBoardLists {
|
||||
@@ -50,6 +64,114 @@ func ParseBoardList(s string) BoardList {
|
||||
}
|
||||
}
|
||||
|
||||
// BoardFilter is a multi-select set of board lists (OR semantics).
|
||||
type BoardFilter []BoardList
|
||||
|
||||
// ParseBoardLists parses a comma-separated list id string (legacy single values work).
|
||||
func ParseBoardLists(s string) BoardFilter {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return BoardFilter{BoardAll}
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
var out BoardFilter
|
||||
seen := map[BoardList]bool{}
|
||||
for _, p := range parts {
|
||||
bl := ParseBoardList(strings.TrimSpace(p))
|
||||
if seen[bl] {
|
||||
continue
|
||||
}
|
||||
seen[bl] = true
|
||||
out = append(out, bl)
|
||||
}
|
||||
return NormalizeFilter(out)
|
||||
}
|
||||
|
||||
// FormatBoardLists serializes a filter for config persistence.
|
||||
func FormatBoardLists(f BoardFilter) string {
|
||||
f = NormalizeFilter(f)
|
||||
parts := make([]string, len(f))
|
||||
for i, bl := range f {
|
||||
parts[i] = string(bl)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// NormalizeFilter ensures a non-empty filter; All alone if empty or only All.
|
||||
// If All is combined with others, All is dropped.
|
||||
func NormalizeFilter(f BoardFilter) BoardFilter {
|
||||
if len(f) == 0 {
|
||||
return BoardFilter{BoardAll}
|
||||
}
|
||||
hasAll := false
|
||||
var others BoardFilter
|
||||
seen := map[BoardList]bool{}
|
||||
for _, bl := range f {
|
||||
if bl == BoardAll {
|
||||
hasAll = true
|
||||
continue
|
||||
}
|
||||
if seen[bl] {
|
||||
continue
|
||||
}
|
||||
seen[bl] = true
|
||||
others = append(others, bl)
|
||||
}
|
||||
if len(others) > 0 {
|
||||
return others
|
||||
}
|
||||
if hasAll {
|
||||
return BoardFilter{BoardAll}
|
||||
}
|
||||
return BoardFilter{BoardAll}
|
||||
}
|
||||
|
||||
// Contains reports whether bl is in the filter.
|
||||
func (f BoardFilter) Contains(bl BoardList) bool {
|
||||
for _, x := range f {
|
||||
if x == bl {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Label returns a short header label for the active filter.
|
||||
func (f BoardFilter) Label() string {
|
||||
f = NormalizeFilter(f)
|
||||
if len(f) == 1 {
|
||||
return f[0].Label()
|
||||
}
|
||||
parts := make([]string, len(f))
|
||||
for i, bl := range f {
|
||||
parts[i] = bl.Label()
|
||||
}
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
// Matches reports whether a ticket status is visible under this filter.
|
||||
func (f BoardFilter) Matches(status Status) bool {
|
||||
f = NormalizeFilter(f)
|
||||
if f.Contains(BoardAll) {
|
||||
return true
|
||||
}
|
||||
for _, bl := range f {
|
||||
if bl.Matches(status) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultStatus is used when creating a ticket under this filter.
|
||||
func (f BoardFilter) DefaultStatus() Status {
|
||||
f = NormalizeFilter(f)
|
||||
if len(f) == 1 {
|
||||
return f[0].DefaultStatus()
|
||||
}
|
||||
return StatusTodo
|
||||
}
|
||||
|
||||
// Matches reports whether a ticket belongs on this board list.
|
||||
func (b BoardList) Matches(status Status) bool {
|
||||
switch b {
|
||||
|
||||
@@ -41,3 +41,38 @@ func TestBoardListDefaultStatus(t *testing.T) {
|
||||
t.Fatal("all default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardFilterMultiSelect(t *testing.T) {
|
||||
f := ticket.ParseBoardLists("backlog,done")
|
||||
if !f.Matches(ticket.StatusTodo) || !f.Matches(ticket.StatusDone) {
|
||||
t.Fatalf("expected backlog+done match, got %v", f)
|
||||
}
|
||||
if f.Matches(ticket.StatusInProgress) {
|
||||
t.Fatal("should not match in progress")
|
||||
}
|
||||
if got := ticket.FormatBoardLists(f); got != "backlog,done" {
|
||||
t.Fatalf("format = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardFilterAllDominatesNormalize(t *testing.T) {
|
||||
f := ticket.NormalizeFilter(ticket.BoardFilter{ticket.BoardAll, ticket.BoardBacklog})
|
||||
if len(f) != 1 || f[0] != ticket.BoardBacklog {
|
||||
t.Fatalf("got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardListShortcut(t *testing.T) {
|
||||
if ticket.BoardAll.Shortcut() != 'a' {
|
||||
t.Fatal("all")
|
||||
}
|
||||
if ticket.BoardBacklog.Shortcut() != 'b' {
|
||||
t.Fatal("backlog")
|
||||
}
|
||||
if ticket.BoardInProgress.Shortcut() != 'i' {
|
||||
t.Fatal("in progress")
|
||||
}
|
||||
if ticket.BoardDone.Shortcut() != 'd' {
|
||||
t.Fatal("done")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// taskCheckboxRe matches GFM/YouTrack-style task list items:
|
||||
//
|
||||
// - [ ] unchecked
|
||||
// - [x] checked
|
||||
// * [X] also accepted
|
||||
var taskCheckboxRe = regexp.MustCompile(`^(\s*)([-*+])\s+\[([ xX])\](\s*)(.*)$`)
|
||||
|
||||
// Checkbox is one task-list item inside a Markdown description.
|
||||
type Checkbox struct {
|
||||
Line int // 0-based line index in the source
|
||||
Checked bool
|
||||
Text string // label after the checkbox marker
|
||||
}
|
||||
|
||||
// ListCheckboxes returns all task-list items in markdown source order.
|
||||
func ListCheckboxes(markdown string) []Checkbox {
|
||||
if markdown == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(markdown, "\n")
|
||||
var out []Checkbox
|
||||
for i, line := range lines {
|
||||
m := taskCheckboxRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Checkbox{
|
||||
Line: i,
|
||||
Checked: m[3] == "x" || m[3] == "X",
|
||||
Text: m[5],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToggleCheckbox flips the checked state of the checkbox at index
|
||||
// (0-based among ListCheckboxes results) and returns the updated markdown.
|
||||
func ToggleCheckbox(markdown string, index int) (string, error) {
|
||||
boxes := ListCheckboxes(markdown)
|
||||
if index < 0 || index >= len(boxes) {
|
||||
return markdown, fmt.Errorf("checkbox %d: not found", index)
|
||||
}
|
||||
lines := strings.Split(markdown, "\n")
|
||||
lineIdx := boxes[index].Line
|
||||
m := taskCheckboxRe.FindStringSubmatch(lines[lineIdx])
|
||||
if m == nil {
|
||||
return markdown, fmt.Errorf("checkbox %d: line changed", index)
|
||||
}
|
||||
mark := " "
|
||||
if !(m[3] == "x" || m[3] == "X") {
|
||||
mark = "x"
|
||||
}
|
||||
lines[lineIdx] = m[1] + m[2] + " [" + mark + "]" + m[4] + m[5]
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListCheckboxes(t *testing.T) {
|
||||
src := `# Steps
|
||||
|
||||
- [ ] one
|
||||
- [x] two
|
||||
* [X] three
|
||||
- [ ] nested
|
||||
|
||||
Not a box: [ ] plain
|
||||
`
|
||||
got := ListCheckboxes(src)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("len=%d want 4: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Text != "one" || got[0].Checked || got[0].Line != 2 {
|
||||
t.Fatalf("first: %+v", got[0])
|
||||
}
|
||||
if !got[1].Checked || got[1].Text != "two" {
|
||||
t.Fatalf("second: %+v", got[1])
|
||||
}
|
||||
if !got[2].Checked || got[2].Text != "three" {
|
||||
t.Fatalf("third: %+v", got[2])
|
||||
}
|
||||
if got[3].Text != "nested" || got[3].Checked || got[3].Line != 5 {
|
||||
t.Fatalf("nested: %+v", got[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleCheckbox(t *testing.T) {
|
||||
src := "- [ ] a\n- [x] b\n"
|
||||
out, err := ToggleCheckbox(src, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "- [x] a\n- [x] b\n" {
|
||||
t.Fatalf("toggle 0: %q", out)
|
||||
}
|
||||
out, err = ToggleCheckbox(out, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "- [x] a\n- [ ] b\n" {
|
||||
t.Fatalf("toggle 1: %q", out)
|
||||
}
|
||||
if _, err := ToggleCheckbox(src, 9); err == nil {
|
||||
t.Fatal("expected error for bad index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleCheckboxPreservesIndentAndBullet(t *testing.T) {
|
||||
src := " * [X] deep"
|
||||
out, err := ToggleCheckbox(src, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != " * [ ] deep" {
|
||||
t.Fatalf("got %q", out)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
reHourMin = regexp.MustCompile(`(?i)^(\d+)\s*h(?:ours?)?\s*(\d+)\s*m(?:ins?)?$`)
|
||||
reHourOnly = regexp.MustCompile(`(?i)^(\d+(?:\.\d+)?)\s*h(?:ours?)?$`)
|
||||
reMinOnly = regexp.MustCompile(`(?i)^(\d+)\s*m(?:ins?)?$`)
|
||||
reSecOnly = regexp.MustCompile(`(?i)^(\d+)\s*s(?:ecs?)?$`)
|
||||
reColon = regexp.MustCompile(`^(\d+):(\d{1,2})$`)
|
||||
)
|
||||
|
||||
// ParseDurationFlexible parses user durations like 1h30m, 90m, 1.5h, 1:30.
|
||||
func ParseDurationFlexible(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("duration required")
|
||||
}
|
||||
// Prefer Go's parser when it works (e.g. 1h30m, 90m).
|
||||
if d, err := time.ParseDuration(strings.ToLower(s)); err == nil && d > 0 {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
if m := reHourMin.FindStringSubmatch(s); m != nil {
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
min, _ := strconv.Atoi(m[2])
|
||||
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
|
||||
}
|
||||
if m := reHourOnly.FindStringSubmatch(s); m != nil {
|
||||
h, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
return time.Duration(h * float64(time.Hour)), nil
|
||||
}
|
||||
if m := reMinOnly.FindStringSubmatch(s); m != nil {
|
||||
min, _ := strconv.Atoi(m[1])
|
||||
return time.Duration(min) * time.Minute, nil
|
||||
}
|
||||
if m := reSecOnly.FindStringSubmatch(s); m != nil {
|
||||
sec, _ := strconv.Atoi(m[1])
|
||||
return time.Duration(sec) * time.Second, nil
|
||||
}
|
||||
if m := reColon.FindStringSubmatch(s); m != nil {
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
min, _ := strconv.Atoi(m[2])
|
||||
if min >= 60 {
|
||||
return 0, fmt.Errorf("invalid minutes in %q", s)
|
||||
}
|
||||
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid duration %q (try 1h30m, 90m, 1.5h)", s)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestParseDurationFlexible(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"1h30m", 90 * time.Minute},
|
||||
{"90m", 90 * time.Minute},
|
||||
{"1.5h", 90 * time.Minute},
|
||||
{"2h", 2 * time.Hour},
|
||||
{"45m", 45 * time.Minute},
|
||||
{"30s", 30 * time.Second},
|
||||
{"1:30", 90 * time.Minute},
|
||||
{" 1h ", time.Hour},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := ticket.ParseDurationFlexible(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("%q: got %v want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationFlexibleRejects(t *testing.T) {
|
||||
for _, in := range []string{"", "abc", "1:99", "-5m"} {
|
||||
if _, err := ticket.ParseDurationFlexible(in); err == nil {
|
||||
t.Fatalf("%q: expected error", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDurationHuman(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{2*time.Hour + 15*time.Minute, "2h15m"},
|
||||
{3 * time.Hour, "3h"},
|
||||
{45 * time.Minute, "45m"},
|
||||
{30 * time.Second, "30s"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ticket.FormatDurationHuman(tc.d); got != tc.want {
|
||||
t.Fatalf("%v: got %q want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,8 @@ func (s *JSONStore) Update(t Ticket) error {
|
||||
for i := range tickets {
|
||||
if tickets[i].ID == t.ID {
|
||||
t.CreatedAt = tickets[i].CreatedAt
|
||||
// Always bump UpdatedAt on local edits so NeedsPush sees dirty tickets.
|
||||
// Use MarkSynced after a successful remote sync to clear the dirty flag.
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
tickets[i] = t
|
||||
found = true
|
||||
@@ -146,6 +148,109 @@ func (s *JSONStore) Update(t Ticket) error {
|
||||
return s.writeUnlocked(t.ProjectID, tickets)
|
||||
}
|
||||
|
||||
// MarkSynced sets SyncedAt = UpdatedAt without changing UpdatedAt.
|
||||
func (s *JSONStore) MarkSynced(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if id == "" {
|
||||
return fmt.Errorf("ticket id is required")
|
||||
}
|
||||
|
||||
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
tickets, err := s.readUnlocked(p.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range tickets {
|
||||
if tickets[i].ID != id {
|
||||
continue
|
||||
}
|
||||
tickets[i].SyncedAt = tickets[i].UpdatedAt
|
||||
return s.writeUnlocked(p.Name(), tickets)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
|
||||
// Move relocates a ticket to another project directory.
|
||||
func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if id == "" {
|
||||
return Ticket{}, fmt.Errorf("ticket id is required")
|
||||
}
|
||||
if toProjectID == "" {
|
||||
return Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
|
||||
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return Ticket{}, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
|
||||
var found Ticket
|
||||
fromProject := ""
|
||||
for _, p := range projects {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
tickets, err := s.readUnlocked(p.Name())
|
||||
if err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
for i, t := range tickets {
|
||||
if t.ID != id {
|
||||
continue
|
||||
}
|
||||
found = t
|
||||
fromProject = p.Name()
|
||||
tickets = append(tickets[:i], tickets[i+1:]...)
|
||||
if err := s.writeUnlocked(fromProject, tickets); err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
break
|
||||
}
|
||||
if fromProject != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if fromProject == "" {
|
||||
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
|
||||
found.ProjectID = toProjectID
|
||||
found.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := os.MkdirAll(s.projectPath(toProjectID), 0o755); err != nil {
|
||||
return Ticket{}, fmt.Errorf("create project dir: %w", err)
|
||||
}
|
||||
dest, err := s.readUnlocked(toProjectID)
|
||||
if err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
dest = append(dest, found)
|
||||
if err := s.writeUnlocked(toProjectID, dest); err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// Delete removes a ticket by ID from its project's file.
|
||||
func (s *JSONStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
@@ -65,6 +66,43 @@ func TestJSONStoreCreateListUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAfterMarkSyncedIsDirty(t *testing.T) {
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
created, err := store.Create(ticket.Ticket{
|
||||
Title: "Synced", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.MarkSynced(created.ID); err != nil {
|
||||
t.Fatalf("MarkSynced: %v", err)
|
||||
}
|
||||
synced, err := store.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if synced.UpdatedAt.After(synced.SyncedAt) {
|
||||
t.Fatalf("freshly marked synced should not be dirty: updated=%v syncedAt=%v",
|
||||
synced.UpdatedAt, synced.SyncedAt)
|
||||
}
|
||||
|
||||
// Simulate UI edit: copy ticket (SyncedAt == UpdatedAt) and change title.
|
||||
edited := synced
|
||||
edited.Title = "Edited locally"
|
||||
time.Sleep(time.Millisecond) // ensure clock can move
|
||||
if err := store.Update(edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.UpdatedAt.After(got.SyncedAt) {
|
||||
t.Fatalf("edit must bump UpdatedAt past SyncedAt: %v vs %v", got.UpdatedAt, got.SyncedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projectDir := filepath.Join(dir, "projects", "inbox")
|
||||
@@ -97,3 +135,33 @@ func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
||||
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStoreMove(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
|
||||
created, err := store.Create(ticket.Ticket{
|
||||
Title: "Misfiled",
|
||||
ProjectID: "inbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
moved, err := store.Move(created.ID, "client")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.ProjectID != "client" {
|
||||
t.Fatalf("ProjectID = %q", moved.ProjectID)
|
||||
}
|
||||
|
||||
from, err := store.List("inbox")
|
||||
if err != nil || len(from) != 0 {
|
||||
t.Fatalf("inbox after move: %v len=%d", err, len(from))
|
||||
}
|
||||
to, err := store.List("client")
|
||||
if err != nil || len(to) != 1 || to[0].ID != created.ID {
|
||||
t.Fatalf("client after move: %v %+v", err, to)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user