Compare commits
20
Commits
98d2253f44
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846bb31dc9 | ||
|
|
2dbf0918de | ||
|
|
98fcb57298 | ||
|
|
229f7a2fe1 | ||
|
|
69c6747a31 | ||
|
|
efeb4a74a8 | ||
|
|
26a3723a06 | ||
|
|
6e6b13270a | ||
|
|
614cb94b46 | ||
|
|
f97856b8a6 | ||
|
|
34ab3b9c5b | ||
|
|
2061df63d4 | ||
|
|
a594183310 | ||
|
|
b311a355fd | ||
|
|
79b0821837 | ||
|
|
afbf86dcf2 | ||
|
|
925bedf259 | ||
|
|
472a2fa72e | ||
|
|
74066649e4 | ||
|
|
4adfcdb95a |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tm": {
|
||||||
|
"command": "${workspaceFolder}/bin/tm",
|
||||||
|
"args": ["mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: make build
|
run: make build
|
||||||
|
|
||||||
|
- name: Build server
|
||||||
|
run: make build-server
|
||||||
|
|
||||||
- name: Version
|
- name: Version
|
||||||
run: make version
|
run: make version
|
||||||
|
|
||||||
@@ -36,7 +39,9 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: tm
|
name: tm
|
||||||
path: ./bin/tm
|
path: |
|
||||||
|
./bin/tm
|
||||||
|
./bin/tm_server
|
||||||
|
|
||||||
- run: echo "This job's status is ${{ job.status }}."
|
- run: echo "This job's status is ${{ job.status }}."
|
||||||
|
|
||||||
|
|||||||
+11
@@ -1,8 +1,19 @@
|
|||||||
/bin/
|
/bin/
|
||||||
.dev-home/
|
.dev-home/
|
||||||
|
.tm_token
|
||||||
|
**/.tm_token
|
||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
# Generated by `make demo` / CI — commit demo.gif so the README preview works on Gitea
|
# Generated by `make demo` / CI — commit demo.gif so the README preview works on Gitea
|
||||||
demo.mp4
|
demo.mp4
|
||||||
demo.webm
|
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,8 +1,10 @@
|
|||||||
BINARY := tm
|
BINARY := tm
|
||||||
CMD := ./cmd/task-master
|
BINARY_SERVER := tm_server
|
||||||
VERSION_PKG := task/internal/version
|
CMD := ./cmd/task-master
|
||||||
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
CMD_SERVER := ./cmd/tm-server
|
||||||
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
VERSION_PKG := task/internal/version
|
||||||
|
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
|
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
||||||
|
|
||||||
# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=...
|
# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=...
|
||||||
GOPATH ?= $(shell go env GOPATH)
|
GOPATH ?= $(shell go env GOPATH)
|
||||||
@@ -10,18 +12,52 @@ PREFIX ?= $(GOPATH)
|
|||||||
INSTALL_BIN := $(PREFIX)/bin
|
INSTALL_BIN := $(PREFIX)/bin
|
||||||
DEV_HOME := $(CURDIR)/.dev-home
|
DEV_HOME := $(CURDIR)/.dev-home
|
||||||
|
|
||||||
.PHONY: build run run-dev install uninstall test version demo
|
.PHONY: build build-server build-all web web-build run run-dev run-server-dev install uninstall test version demo
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
|
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
|
run: build
|
||||||
./bin/$(BINARY)
|
./bin/$(BINARY)
|
||||||
|
|
||||||
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config)
|
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config).
|
||||||
|
# YouTrack is not part of the local test environment — use tm_server instead.
|
||||||
run-dev: build
|
run-dev: build
|
||||||
|
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
|
||||||
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
|
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: build
|
||||||
install -d $(INSTALL_BIN)
|
install -d $(INSTALL_BIN)
|
||||||
install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY)
|
install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# task-master
|
# task-master
|
||||||
|
|
||||||
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea).
|
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea). Dazu der optionale Web-Server `tm_server` mit Svelte-UI und SQLite.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -8,18 +8,35 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Projekte & Tickets lokal als JSON (kein Server)
|
**TUI (`tm`)**
|
||||||
|
|
||||||
|
- Projekte & Tickets lokal als JSON hinter einem Hintergrund-Daemon (Unix-Socket)
|
||||||
|
- Mehrere TUI-Instanzen teilen denselben State; `tm stop` beendet den Daemon
|
||||||
- Board-Filter: All / Backlog / In Progress / Done
|
- Board-Filter: All / Backlog / In Progress / Done
|
||||||
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
|
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
|
||||||
- Timer, manuelle Zeitbuchungen, Zeiteinträge verwalten
|
- Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren
|
||||||
- Report mit Group-by (Ticket / Tag)
|
- Report mit Group-by (Ticket / Tag)
|
||||||
- Undo / Redo über eine Command-History
|
- Undo / Redo über eine Command-History (im Daemon)
|
||||||
- Vim-Style Keybindings
|
- Vim-Style Keybindings
|
||||||
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
|
- 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
|
## Install
|
||||||
|
|
||||||
Voraussetzung: Go (siehe `go.mod`).
|
Voraussetzung: Go (siehe `go.mod`). Für `tm_server` zusätzlich Node.js (Frontend-Build).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.aloxdrim.de:skruecken/task-master.git
|
git clone git@git.aloxdrim.de:skruecken/task-master.git
|
||||||
@@ -27,19 +44,70 @@ cd task-master
|
|||||||
make install # → $(go env GOPATH)/bin/tm
|
make install # → $(go env GOPATH)/bin/tm
|
||||||
# oder nur bauen:
|
# oder nur bauen:
|
||||||
make build # → ./bin/tm
|
make build # → ./bin/tm
|
||||||
|
make build-server # Frontend + → ./bin/tm_server
|
||||||
|
make build-all # beides
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nutzung
|
## Nutzung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tm # TUI starten
|
tm # TUI starten (startet Daemon bei Bedarf)
|
||||||
tm sync pull # Issues von YouTrack holen
|
tm sync pull # Issues von YouTrack / tm_server holen
|
||||||
tm sync push # lokale Änderungen nach YouTrack
|
tm sync push # lokale Änderungen pushen
|
||||||
tm sync pull -p inbox # anderes Projekt
|
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 version # Build-Version (Git-Hash)
|
||||||
tm help
|
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:
|
Sandbox ohne echte Config/Daten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -50,10 +118,16 @@ TASK_MASTER_HOME=/tmp/tm-sandbox tm
|
|||||||
|
|
||||||
Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~/.config/task-master`.
|
Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~/.config/task-master`.
|
||||||
|
|
||||||
| Variable | Wirkung |
|
| Variable | Wirkung |
|
||||||
|----------------------|----------------------------------------------|
|
|------------------------|---------------------------------------------------|
|
||||||
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
|
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
|
||||||
| `TASK_MASTER_DATA_DIR` | Nur Datenverzeichnis überschreiben |
|
| `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)
|
## Keybindings (Auszug)
|
||||||
|
|
||||||
@@ -61,13 +135,12 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|
|||||||
|---------|------------------------|
|
|---------|------------------------|
|
||||||
| `j`/`k` | Navigieren |
|
| `j`/`k` | Navigieren |
|
||||||
| `n` | Neues Ticket/Projekt |
|
| `n` | Neues Ticket/Projekt |
|
||||||
| `e` | Ticket im Editor |
|
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack / Sync-Backend) |
|
||||||
| `s` | Status ändern |
|
| `s` | Status ändern |
|
||||||
| `l` | Board-Filter |
|
| `l` | Board-Filter |
|
||||||
| `/` | Suche |
|
| `/` | Suche |
|
||||||
| `p`/`P` | Projekt / Ticket verschieben |
|
| `p`/`P` | Projekt / Ticket verschieben |
|
||||||
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack) |
|
| `Y` | Sync-Menü: Pull / Push |
|
||||||
| `Y` | YouTrack-Menü: Pull / Push |
|
|
||||||
| `t`/`T` | Timer / Zeit buchen |
|
| `t`/`T` | Timer / Zeit buchen |
|
||||||
| `m` | Zeiteinträge |
|
| `m` | Zeiteinträge |
|
||||||
| `r` | Report |
|
| `r` | Report |
|
||||||
@@ -77,15 +150,15 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|
|||||||
|
|
||||||
Checklisten im Preview: `J`/`K` + `Space`.
|
Checklisten im Preview: `J`/`K` + `Space`.
|
||||||
|
|
||||||
### YouTrack
|
### YouTrack / Sync
|
||||||
|
|
||||||
1. `p` → Projekt wählen → `e`
|
1. `p` → Projekt wählen → `e`
|
||||||
2. Remote-URL, Permanent Token und Project-Key setzen (gespeichert in `projects/<id>/meta.json`, Mode `0600`)
|
2. Backend wählen: YouTrack (Remote-URL, Permanent Token, Project-Key) oder task-master (Server-URL + Projekt-Token)
|
||||||
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
|
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
|
||||||
4. `Y` → **Pull** (Issues + Work Items) oder **Push** (Tickets + Zeiteinträge)
|
4. `Y` → **Pull** oder **Push**
|
||||||
CLI: `tm sync pull` / `tm sync push`
|
CLI: `tm sync pull` / `tm sync push`
|
||||||
|
|
||||||
Zeit: lokale Einträge ohne `externalId` werden als YouTrack-Work-Items gepusht; remote Work Items landen lokal beim Pull.
|
Zeit: lokale Einträge ohne `externalId` werden remote als Work-Items gepusht; remote Work Items landen lokal beim Pull.
|
||||||
|
|
||||||
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
|
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
|
||||||
|
|
||||||
@@ -94,9 +167,12 @@ Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fi
|
|||||||
```bash
|
```bash
|
||||||
make test
|
make test
|
||||||
make build
|
make build
|
||||||
|
make build-server # braucht Node (npm ci + Vite-Build)
|
||||||
make version
|
make version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
CI (Gitea): Tests, `tm` + `tm_server`-Build, Demo-GIF als Artifact.
|
||||||
|
|
||||||
## Demo aufnehmen
|
## Demo aufnehmen
|
||||||
|
|
||||||
Die Walkthrough-Aufnahme steckt in [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)):
|
Die Walkthrough-Aufnahme steckt in [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)):
|
||||||
|
|||||||
+158
-43
@@ -8,13 +8,13 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"task/internal/command"
|
|
||||||
"task/internal/config"
|
"task/internal/config"
|
||||||
"task/internal/project"
|
"task/internal/daemon"
|
||||||
"task/internal/ticket"
|
"task/internal/daemon/api"
|
||||||
|
"task/internal/daemon/client"
|
||||||
|
mcpserver "task/internal/mcp"
|
||||||
"task/internal/ui"
|
"task/internal/ui"
|
||||||
"task/internal/version"
|
"task/internal/version"
|
||||||
"task/internal/ytsync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -32,6 +32,30 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return
|
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:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||||
printHelp()
|
printHelp()
|
||||||
@@ -45,35 +69,104 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func loadConfig() (*config.Config, error) {
|
||||||
cfg, err := config.LoadOrCreate()
|
cfg, err := config.LoadOrCreate()
|
||||||
if err != nil {
|
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)
|
func ensureClient(kind string) (*config.Config, *client.Client, error) {
|
||||||
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
|
cfg, err := loadConfig()
|
||||||
projects := project.NewDirStore(cfg.DataDir)
|
if err != nil {
|
||||||
if err := projects.Ensure(cfg.ActiveProject); 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)
|
return fmt.Errorf("ensure project: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
|
model := ui.New(cfg, cli.TicketStore(), cli.ProjectStore(), cli.TimeStore(), cli.Executor(), cli)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("history: %w", err)
|
|
||||||
}
|
|
||||||
model := ui.New(cfg, store, projects, timeStore, history)
|
|
||||||
prog := tea.NewProgram(model, tea.WithAltScreen())
|
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 {
|
if _, err := prog.Run(); err != nil {
|
||||||
return fmt.Errorf("tui: %w", err)
|
return fmt.Errorf("tui: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSync(args []string) error {
|
func runDaemon() error {
|
||||||
cfg, err := config.LoadOrCreate()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("config: %w", err)
|
return err
|
||||||
|
}
|
||||||
|
return daemon.Run(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStop() error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := daemon.Stop(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("daemon stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus() error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := daemon.StatusOf(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state := "stopped"
|
||||||
|
if st.Running {
|
||||||
|
state = "running"
|
||||||
|
}
|
||||||
|
fmt.Printf("status: %s\n", state)
|
||||||
|
fmt.Printf("socket: %s\n", st.Socket)
|
||||||
|
fmt.Printf("dataDir: %s\n", st.DataDir)
|
||||||
|
if st.PID > 0 {
|
||||||
|
fmt.Printf("pid: %d\n", st.PID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSync(args []string) error {
|
||||||
|
cfg, cli, err := ensureClient(api.ClientCLI)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
projectID := cfg.ActiveProject
|
projectID := cfg.ActiveProject
|
||||||
action := ""
|
action := ""
|
||||||
@@ -104,39 +197,61 @@ func runSync(args []string) error {
|
|||||||
return fmt.Errorf("project id required")
|
return fmt.Errorf("project id required")
|
||||||
}
|
}
|
||||||
|
|
||||||
store := ticket.NewJSONStore(cfg.DataDir)
|
if action == "push" {
|
||||||
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
|
out, err := cli.SyncPush(projectID)
|
||||||
projects := project.NewDirStore(cfg.DataDir)
|
|
||||||
if err := projects.Ensure(projectID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch action {
|
|
||||||
case "push":
|
|
||||||
res, err := ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
|
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
|
||||||
projectID, res.Created, res.Updated, res.TimePushed)
|
projectID, out.Created, out.Updated, out.TimePushed)
|
||||||
default:
|
return nil
|
||||||
res, err := ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
|
|
||||||
projectID, res.Created, res.Updated, res.TimePulled, res.Total)
|
|
||||||
}
|
}
|
||||||
|
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
|
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() {
|
func printHelp() {
|
||||||
fmt.Printf(`%s — offline-first terminal task manager
|
fmt.Printf(`%s — offline-first terminal task manager
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
%s Start the TUI
|
%s Start the TUI (starts local daemon if needed)
|
||||||
%s sync pull [-p ID] Pull issues from YouTrack
|
%s sync pull [-p ID] Pull via daemon from configured backend
|
||||||
%s sync push [-p ID] Push local changes to YouTrack
|
%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 version Print build version
|
||||||
%s help Show this help
|
%s help Show this help
|
||||||
|
|
||||||
@@ -144,9 +259,9 @@ Env:
|
|||||||
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
|
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
|
||||||
TASK_MASTER_DATA_DIR Override data directory only
|
TASK_MASTER_DATA_DIR Override data directory only
|
||||||
|
|
||||||
YouTrack:
|
The TUI talks to a per-DataDir background daemon over a Unix socket so multiple
|
||||||
In the TUI: p → project → e to set remote URL, token, project key.
|
instances stay consistent. Create an MCP token from the project list (key g);
|
||||||
Then Y → Pull or Push (manual only; edits stay local until you push).
|
place project= and token= in a repo .tm_token file, then run: tm mcp
|
||||||
|
|
||||||
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ require (
|
|||||||
github.com/charmbracelet/glamour v1.0.0
|
github.com/charmbracelet/glamour v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/mark3labs/mcp-go v0.32.0
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
|
golang.org/x/crypto v0.58.0
|
||||||
|
modernc.org/sqlite v1.61.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -25,23 +28,31 @@ require (
|
|||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // 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-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // 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/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1 // 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/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 v1.7.13 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.60.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
golang.org/x/term v0.36.0 // indirect
|
golang.org/x/term v0.47.0 // indirect
|
||||||
golang.org/x/text v0.30.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/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 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
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 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
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=
|
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/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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.2.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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
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 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
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/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
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.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.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
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=
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ import (
|
|||||||
|
|
||||||
const metaFileName = "meta.json"
|
const metaFileName = "meta.json"
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendNone = "none"
|
||||||
|
BackendYouTrack = "youtrack"
|
||||||
|
BackendTaskMaster = "taskmaster"
|
||||||
|
)
|
||||||
|
|
||||||
// YouTrack holds remote sync settings for a project.
|
// YouTrack holds remote sync settings for a project.
|
||||||
type YouTrack struct {
|
type YouTrack struct {
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
@@ -34,9 +40,66 @@ func (y YouTrack) IssueQuery() string {
|
|||||||
return "project: " + strings.TrimSpace(y.ProjectKey)
|
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.
|
// Meta is per-project metadata stored beside tickets.json.
|
||||||
type Meta struct {
|
type Meta struct {
|
||||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
|
||||||
|
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||||
|
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
|
||||||
|
McpAuth *McpAuth `json:"mcpAuth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
|
||||||
|
func (m Meta) EffectiveBackend() string {
|
||||||
|
b := strings.TrimSpace(strings.ToLower(m.Backend))
|
||||||
|
switch b {
|
||||||
|
case BackendYouTrack, BackendTaskMaster, BackendNone:
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if m.YouTrack != nil && m.YouTrack.Configured() {
|
||||||
|
return BackendYouTrack
|
||||||
|
}
|
||||||
|
if m.TaskMaster != nil && m.TaskMaster.Configured() {
|
||||||
|
return BackendTaskMaster
|
||||||
|
}
|
||||||
|
if m.YouTrack != nil {
|
||||||
|
return BackendYouTrack
|
||||||
|
}
|
||||||
|
if m.TaskMaster != nil {
|
||||||
|
return BackendTaskMaster
|
||||||
|
}
|
||||||
|
return BackendNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncConfigured reports whether the active backend is fully set up.
|
||||||
|
func (m Meta) SyncConfigured() bool {
|
||||||
|
switch m.EffectiveBackend() {
|
||||||
|
case BackendYouTrack:
|
||||||
|
return m.YouTrack != nil && m.YouTrack.Configured()
|
||||||
|
case BackendTaskMaster:
|
||||||
|
return m.TaskMaster != nil && m.TaskMaster.Configured()
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DirStore) metaPath(id string) string {
|
func (s *DirStore) metaPath(id string) string {
|
||||||
@@ -63,6 +126,12 @@ func (s *DirStore) LoadMeta(id string) (Meta, error) {
|
|||||||
if err := json.Unmarshal(data, &meta); err != nil {
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
|
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
|
return meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +143,9 @@ func (s *DirStore) SaveMeta(id string, meta Meta) error {
|
|||||||
if err := s.Ensure(id); err != nil {
|
if err := s.Ensure(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if meta.Backend == "" {
|
||||||
|
meta.Backend = meta.EffectiveBackend()
|
||||||
|
}
|
||||||
path := s.metaPath(id)
|
path := s.metaPath(id)
|
||||||
data, err := json.MarshalIndent(meta, "", " ")
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package project
|
|||||||
|
|
||||||
// Project is a lightweight workspace identified by a directory name.
|
// Project is a lightweight workspace identified by a directory name.
|
||||||
type Project struct {
|
type Project struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
GroupID string `json:"groupId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package ticket
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,3 +156,62 @@ func TotalDuration(entries []TimeEntry) time.Duration {
|
|||||||
}
|
}
|
||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TimeFilter selects which time entries belong in a report.
|
||||||
|
// From/To are inclusive bounds on the entry's work instant (Start, else End).
|
||||||
|
// Zero times mean no bound on that side.
|
||||||
|
type TimeFilter struct {
|
||||||
|
From time.Time
|
||||||
|
To time.Time
|
||||||
|
TicketID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterEntries returns entries matching f. The input slice is not modified.
|
||||||
|
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
|
||||||
|
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
|
||||||
|
out := make([]TimeEntry, len(entries))
|
||||||
|
copy(out, entries)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out := make([]TimeEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if f.TicketID != "" && e.TicketID != f.TicketID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when := e.Start
|
||||||
|
if when.IsZero() {
|
||||||
|
when = e.End
|
||||||
|
}
|
||||||
|
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
|
||||||
|
func ParseReportDay(s string) (time.Time, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("invalid date %q", s)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndOfReportDay returns the last instant of the calendar day of t in local time.
|
||||||
|
func EndOfReportDay(t time.Time) time.Time {
|
||||||
|
if t.IsZero() {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
local := t.In(time.Local)
|
||||||
|
y, m, d := local.Date()
|
||||||
|
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,3 +70,48 @@ func TestAggregateByDay(t *testing.T) {
|
|||||||
t.Fatalf("older day dur=%v", rows[1].Duration)
|
t.Fatalf("older day dur=%v", rows[1].Duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterEntries(t *testing.T) {
|
||||||
|
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
|
||||||
|
d2 := time.Date(2026, 10, 20, 11, 0, 0, 0, time.Local)
|
||||||
|
entries := []ticket.TimeEntry{
|
||||||
|
{ID: "1", TicketID: "a", Start: d1, Duration: time.Hour},
|
||||||
|
{ID: "2", TicketID: "b", Start: d2, Duration: 30 * time.Minute},
|
||||||
|
{ID: "3", TicketID: "a", Start: d2, Duration: 15 * time.Minute},
|
||||||
|
}
|
||||||
|
|
||||||
|
byTicket := ticket.FilterEntries(entries, ticket.TimeFilter{TicketID: "a"})
|
||||||
|
if len(byTicket) != 2 {
|
||||||
|
t.Fatalf("ticket filter=%d", len(byTicket))
|
||||||
|
}
|
||||||
|
|
||||||
|
from := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
|
||||||
|
to := ticket.EndOfReportDay(time.Date(2026, 10, 20, 0, 0, 0, 0, time.Local))
|
||||||
|
byRange := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to})
|
||||||
|
if len(byRange) != 2 {
|
||||||
|
t.Fatalf("range filter=%d", len(byRange))
|
||||||
|
}
|
||||||
|
|
||||||
|
both := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to, TicketID: "a"})
|
||||||
|
if len(both) != 1 || both[0].ID != "3" {
|
||||||
|
t.Fatalf("combined=%+v", both)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReportDay(t *testing.T) {
|
||||||
|
got, err := ticket.ParseReportDay("2026-10-18")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
y, m, d := got.Date()
|
||||||
|
if y != 2026 || m != 10 || d != 18 || got.Hour() != 0 {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
empty, err := ticket.ParseReportDay("")
|
||||||
|
if err != nil || !empty.IsZero() {
|
||||||
|
t.Fatalf("empty: %v %v", empty, err)
|
||||||
|
}
|
||||||
|
if _, err := ticket.ParseReportDay("nope"); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ func (s Status) Index() int {
|
|||||||
const (
|
const (
|
||||||
// SourceYouTrack marks tickets imported from YouTrack.
|
// SourceYouTrack marks tickets imported from YouTrack.
|
||||||
SourceYouTrack = "youtrack"
|
SourceYouTrack = "youtrack"
|
||||||
|
// SourceTaskMaster marks tickets synced from a task-master server.
|
||||||
|
SourceTaskMaster = "taskmaster"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ticket is the domain model for a work item.
|
// Ticket is the domain model for a work item.
|
||||||
@@ -66,9 +68,22 @@ type Ticket struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
|
||||||
|
// Readable key assigned by the server (e.g. "ABC-42"). Optional on local-only tickets.
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
|
||||||
// External sync (optional). Local UUID stays in ID.
|
// External sync (optional). Local UUID stays in ID.
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
ExternalID string `json:"externalId,omitempty"`
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
ExternalURL string `json:"externalUrl,omitempty"`
|
ExternalURL string `json:"externalUrl,omitempty"`
|
||||||
SyncedAt time.Time `json:"syncedAt,omitempty"`
|
SyncedAt time.Time `json:"syncedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayID returns the human-readable ticket label for UI.
|
||||||
|
// Prefers the server key; falls back to ExternalID (e.g. YouTrack idReadable).
|
||||||
|
func (t Ticket) DisplayID() string {
|
||||||
|
if t.Key != "" {
|
||||||
|
return t.Key
|
||||||
|
}
|
||||||
|
return t.ExternalID
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package tmsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"task/internal/project"
|
||||||
|
"task/internal/ticket"
|
||||||
|
"task/internal/ytsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result reuses ytsync.Result for consistent CLI/UI messaging.
|
||||||
|
type Result = ytsync.Result
|
||||||
|
|
||||||
|
// Syncer syncs a local project with a remote tm_server project.
|
||||||
|
type Syncer struct {
|
||||||
|
Store ticket.Store
|
||||||
|
TimeStore ticket.TimeStore
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) http() *http.Client {
|
||||||
|
if s.HTTP != nil {
|
||||||
|
return s.HTTP
|
||||||
|
}
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsPush reports whether a local ticket should be pushed to task-master.
|
||||||
|
func NeedsPush(t ticket.Ticket) bool {
|
||||||
|
if t.ExternalID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if t.Source != ticket.SourceTaskMaster {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t.SyncedAt.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return t.UpdatedAt.After(t.SyncedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull imports remote tickets/time into localProjectID.
|
||||||
|
func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
|
||||||
|
if !tm.Configured() {
|
||||||
|
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
|
||||||
|
}
|
||||||
|
if s.Store == nil {
|
||||||
|
return Result{}, fmt.Errorf("ticket store required")
|
||||||
|
}
|
||||||
|
remote, err := s.listTickets(ctx, tm)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
existing, err := s.Store.List(localProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
byExt := map[string]ticket.Ticket{}
|
||||||
|
for _, t := range existing {
|
||||||
|
if t.ExternalID != "" {
|
||||||
|
byExt[t.ExternalID] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var res Result
|
||||||
|
res.Total = len(remote)
|
||||||
|
for _, rt := range remote {
|
||||||
|
if rt.ID == "" {
|
||||||
|
res.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extURL := strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + rt.ID
|
||||||
|
if prev, ok := byExt[rt.ID]; ok {
|
||||||
|
if NeedsPush(prev) {
|
||||||
|
res.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := prev
|
||||||
|
next.Title = rt.Title
|
||||||
|
next.Description = rt.Description
|
||||||
|
next.Status = rt.Status
|
||||||
|
next.Source = ticket.SourceTaskMaster
|
||||||
|
next.ExternalURL = extURL
|
||||||
|
next.Number = rt.Number
|
||||||
|
next.Key = rt.Key
|
||||||
|
if sameFields(prev, next) {
|
||||||
|
_ = s.Store.MarkSynced(prev.ID)
|
||||||
|
res.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.Store.Update(next); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
_ = s.Store.MarkSynced(next.ID)
|
||||||
|
res.Updated++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := s.Store.Create(ticket.Ticket{
|
||||||
|
Title: rt.Title, Description: rt.Description, Status: rt.Status,
|
||||||
|
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
|
||||||
|
ExternalID: rt.ID, ExternalURL: extURL,
|
||||||
|
Number: rt.Number, Key: rt.Key,
|
||||||
|
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.Created++
|
||||||
|
}
|
||||||
|
if err := s.pullTime(ctx, tm, localProjectID, &res); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push sends dirty local tickets/time to the remote project.
|
||||||
|
func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
|
||||||
|
if !tm.Configured() {
|
||||||
|
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
|
||||||
|
}
|
||||||
|
if s.Store == nil {
|
||||||
|
return Result{}, fmt.Errorf("ticket store required")
|
||||||
|
}
|
||||||
|
existing, err := s.Store.List(localProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
var res Result
|
||||||
|
for _, t := range existing {
|
||||||
|
if !NeedsPush(t) {
|
||||||
|
res.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Total++
|
||||||
|
if t.ExternalID == "" {
|
||||||
|
created, err := s.createRemote(ctx, tm, t)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
t.ExternalID = created.ID
|
||||||
|
t.Number = created.Number
|
||||||
|
t.Key = created.Key
|
||||||
|
t.Source = ticket.SourceTaskMaster
|
||||||
|
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
|
||||||
|
if err := s.Store.Update(t); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
_ = s.Store.MarkSynced(t.ID)
|
||||||
|
res.Created++
|
||||||
|
res.Pushed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.patchRemote(ctx, tm, t); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
_ = s.Store.MarkSynced(t.ID)
|
||||||
|
res.Updated++
|
||||||
|
res.Pushed++
|
||||||
|
}
|
||||||
|
if err := s.pushTime(ctx, tm, localProjectID, &res); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameFields(a, b ticket.Ticket) bool {
|
||||||
|
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status &&
|
||||||
|
a.Key == b.Key && a.Number == b.Number
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
|
||||||
|
var out []ticket.Ticket
|
||||||
|
err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/tickets", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) createRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) (ticket.Ticket, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"title": t.Title, "description": t.Description, "status": t.Status,
|
||||||
|
}
|
||||||
|
var created ticket.Ticket
|
||||||
|
err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/tickets", body, &created)
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) patchRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) error {
|
||||||
|
body := map[string]any{
|
||||||
|
"title": t.Title, "description": t.Description, "status": t.Status,
|
||||||
|
}
|
||||||
|
return s.doJSON(ctx, tm, http.MethodPatch,
|
||||||
|
"/projects/"+tm.ProjectID+"/tickets/"+t.ExternalID, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) pullTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
|
||||||
|
if s.TimeStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var remote []ticket.TimeEntry
|
||||||
|
if err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/time", nil, &remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
localTickets, err := s.Store.List(localProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
localByExt := map[string]ticket.Ticket{}
|
||||||
|
for _, t := range localTickets {
|
||||||
|
if t.ExternalID != "" {
|
||||||
|
localByExt[t.ExternalID] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing, err := s.TimeStore.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, e := range existing {
|
||||||
|
if e.ExternalID != "" {
|
||||||
|
have[e.ExternalID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range remote {
|
||||||
|
if e.ID == "" || have[e.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lt, ok := localByExt[e.TicketID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := s.TimeStore.Create(ticket.TimeEntry{
|
||||||
|
TicketID: lt.ID, ProjectID: localProjectID,
|
||||||
|
Start: e.Start, End: e.End, Duration: e.Duration,
|
||||||
|
Source: ticket.SourceTaskMaster, Note: e.Note, ExternalID: e.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res.TimePulled++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) pushTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
|
||||||
|
if s.TimeStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tickets, err := s.Store.List(localProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byID := map[string]ticket.Ticket{}
|
||||||
|
for _, t := range tickets {
|
||||||
|
byID[t.ID] = t
|
||||||
|
}
|
||||||
|
entries, err := s.TimeStore.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.ProjectID != localProjectID || e.ExternalID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, ok := byID[e.TicketID]
|
||||||
|
if !ok || t.ExternalID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"ticketId": t.ExternalID,
|
||||||
|
"durationNs": int64(e.Duration),
|
||||||
|
"note": e.Note,
|
||||||
|
"source": e.Source,
|
||||||
|
}
|
||||||
|
var created ticket.TimeEntry
|
||||||
|
if err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/time", body, &created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.ExternalID = created.ID
|
||||||
|
if err := s.TimeStore.Update(e); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res.TimePushed++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) doJSON(ctx context.Context, tm project.TaskMaster, method, path string, body any, dest any) error {
|
||||||
|
var rdr io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rdr = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
url := strings.TrimRight(strings.TrimSpace(tm.BaseURL), "/") + "/api/v1" + path
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tm.Token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := s.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("taskmaster: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
var eresp struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &eresp)
|
||||||
|
msg := eresp.Error
|
||||||
|
if msg == "" {
|
||||||
|
msg = string(data)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("taskmaster: %s: %s", resp.Status, msg)
|
||||||
|
}
|
||||||
|
if dest == nil || len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, dest)
|
||||||
|
}
|
||||||
+132
-32
@@ -23,6 +23,19 @@ import (
|
|||||||
"task/internal/ytsync"
|
"task/internal/ytsync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DaemonChangedMsg is sent when the daemon publishes a change event (SSE).
|
||||||
|
type DaemonChangedMsg struct {
|
||||||
|
ProjectID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteOps is the optional daemon client surface for sync and MCP tokens.
|
||||||
|
type RemoteOps interface {
|
||||||
|
SyncPull(projectID string) (ytsync.Result, error)
|
||||||
|
SyncPush(projectID string) (ytsync.Result, error)
|
||||||
|
CreateMCPToken(projectID string) (token, createdAt string, err error)
|
||||||
|
RevokeMCPToken(projectID string) error
|
||||||
|
}
|
||||||
|
|
||||||
// timerTickMsg refreshes the header while a timer is running.
|
// timerTickMsg refreshes the header while a timer is running.
|
||||||
type timerTickMsg time.Time
|
type timerTickMsg time.Time
|
||||||
|
|
||||||
@@ -87,6 +100,7 @@ type Model struct {
|
|||||||
timeEntries views.TimeEntriesView
|
timeEntries views.TimeEntriesView
|
||||||
timeEntriesTicket string // ticket ID for ModeTimeEntries
|
timeEntriesTicket string // ticket ID for ModeTimeEntries
|
||||||
editingTimeEntry ticket.TimeEntry
|
editingTimeEntry ticket.TimeEntry
|
||||||
|
timeEdit views.TimeEditForm
|
||||||
activeTimer *ticket.ActiveTimer
|
activeTimer *ticket.ActiveTimer
|
||||||
spentByID map[string]time.Duration
|
spentByID map[string]time.Duration
|
||||||
width int
|
width int
|
||||||
@@ -104,10 +118,11 @@ type Model struct {
|
|||||||
projectEdit views.ProjectEditForm
|
projectEdit views.ProjectEditForm
|
||||||
projectEditMeta project.Meta // snapshot when opening the form
|
projectEditMeta project.Meta // snapshot when opening the form
|
||||||
syncing bool
|
syncing bool
|
||||||
|
remote RemoteOps
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates the root UI model.
|
// New creates the root UI model.
|
||||||
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model {
|
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor, remote RemoteOps) Model {
|
||||||
styles := DefaultStyles()
|
styles := DefaultStyles()
|
||||||
if exec == nil {
|
if exec == nil {
|
||||||
exec = command.NewHistory()
|
exec = command.NewHistory()
|
||||||
@@ -119,6 +134,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, timeSto
|
|||||||
timeStore: timeStore,
|
timeStore: timeStore,
|
||||||
projects: projects,
|
projects: projects,
|
||||||
exec: exec,
|
exec: exec,
|
||||||
|
remote: remote,
|
||||||
mode: ModeList,
|
mode: ModeList,
|
||||||
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
||||||
reportGroupBy: ticket.GroupByProject,
|
reportGroupBy: ticket.GroupByProject,
|
||||||
@@ -165,6 +181,15 @@ func timerTickCmd() tea.Cmd {
|
|||||||
// Update implements tea.Model.
|
// Update implements tea.Model.
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case DaemonChangedMsg:
|
||||||
|
_ = m.reloadTickets()
|
||||||
|
if m.activeTimer != nil && m.timeStore != nil {
|
||||||
|
if t, err := m.timeStore.LoadActiveTimer(); err == nil {
|
||||||
|
m.activeTimer = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
@@ -206,8 +231,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
case ModeCreate, ModeSearch, ModeTimeManual:
|
||||||
return m.updateInput(msg)
|
return m.updateInput(msg)
|
||||||
|
case ModeTimeEdit:
|
||||||
|
return m.updateTimeEdit(msg)
|
||||||
case ModeEditProject:
|
case ModeEditProject:
|
||||||
return m.updateEditProject(msg)
|
return m.updateEditProject(msg)
|
||||||
case ModeProjects:
|
case ModeProjects:
|
||||||
@@ -237,8 +264,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch m.mode {
|
switch m.mode {
|
||||||
case ModeProjects, ModeMoveTicket:
|
case ModeProjects, ModeMoveTicket:
|
||||||
m.projectList, cmd = m.projectList.Update(msg)
|
m.projectList, cmd = m.projectList.Update(msg)
|
||||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
case ModeCreate, ModeSearch, ModeTimeManual:
|
||||||
m.input, cmd = m.input.Update(msg)
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
case ModeTimeEdit:
|
||||||
|
cmd = m.timeEdit.Update(msg)
|
||||||
case ModeEditProject:
|
case ModeEditProject:
|
||||||
cmd = m.projectEdit.Update(msg)
|
cmd = m.projectEdit.Update(msg)
|
||||||
}
|
}
|
||||||
@@ -584,8 +613,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch {
|
switch {
|
||||||
case wasProject:
|
case wasProject:
|
||||||
m.mode = ModeProjects
|
m.mode = ModeProjects
|
||||||
case m.mode == ModeTimeEdit:
|
|
||||||
m.mode = ModeTimeEntries
|
|
||||||
default:
|
default:
|
||||||
m.mode = ModeList
|
m.mode = ModeList
|
||||||
}
|
}
|
||||||
@@ -607,16 +634,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, m.keys.Enter):
|
case key.Matches(msg, m.keys.Enter):
|
||||||
value := strings.TrimSpace(m.input.Value())
|
value := strings.TrimSpace(m.input.Value())
|
||||||
if m.mode == ModeTimeEdit {
|
|
||||||
if err := m.saveEditedTime(value); err != nil {
|
|
||||||
m.setError(err)
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.input.Blur()
|
|
||||||
m.mode = ModeTimeEntries
|
|
||||||
m.setStatus("time updated")
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.mode == ModeTimeManual {
|
if m.mode == ModeTimeManual {
|
||||||
if err := m.addManualTime(value); err != nil {
|
if err := m.addManualTime(value); err != nil {
|
||||||
m.setError(err)
|
m.setError(err)
|
||||||
@@ -707,10 +724,8 @@ func (m Model) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.editingTimeEntry = e
|
m.editingTimeEntry = e
|
||||||
|
m.timeEdit = views.NewTimeEditForm(e, m.width)
|
||||||
m.mode = ModeTimeEdit
|
m.mode = ModeTimeEdit
|
||||||
m.input = views.NewTimeManualInput()
|
|
||||||
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
|
|
||||||
m.input.Focus()
|
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Undo):
|
case key.Matches(msg, m.keys.Undo):
|
||||||
@@ -826,6 +841,14 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.projectEdit.PrevField()
|
m.projectEdit.PrevField()
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case msg.Type == tea.KeyLeft:
|
||||||
|
m.projectEdit.CycleBackend(-1)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case msg.Type == tea.KeyRight:
|
||||||
|
m.projectEdit.CycleBackend(1)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Enter):
|
case key.Matches(msg, m.keys.Enter):
|
||||||
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
|
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
|
||||||
m.setError(err)
|
m.setError(err)
|
||||||
@@ -842,10 +865,10 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.mode = ModeProjects
|
m.mode = ModeProjects
|
||||||
if meta.YouTrack != nil && meta.YouTrack.Configured() {
|
if meta.SyncConfigured() {
|
||||||
m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id))
|
m.setStatus(fmt.Sprintf("saved %s sync for %s", meta.EffectiveBackend(), id))
|
||||||
} else {
|
} else {
|
||||||
m.setStatus(fmt.Sprintf("cleared YouTrack settings for %s", id))
|
m.setStatus(fmt.Sprintf("cleared sync settings for %s", id))
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -866,12 +889,21 @@ func (m *Model) openProjectEdit(projectID string) error {
|
|||||||
|
|
||||||
func (m *Model) openSyncMenu(projectID string) {
|
func (m *Model) openSyncMenu(projectID string) {
|
||||||
m.syncProjectID = projectID
|
m.syncProjectID = projectID
|
||||||
|
label := "Sync"
|
||||||
|
if meta, err := m.projects.LoadMeta(projectID); err == nil {
|
||||||
|
switch meta.EffectiveBackend() {
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
label = "task-master"
|
||||||
|
case project.BackendYouTrack:
|
||||||
|
label = "YouTrack"
|
||||||
|
}
|
||||||
|
}
|
||||||
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
|
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
|
||||||
Box: m.styles.StatusMenuBox,
|
Box: m.styles.StatusMenuBox,
|
||||||
Title: m.styles.StatusMenuTitle,
|
Title: m.styles.StatusMenuTitle,
|
||||||
Selected: m.styles.StatusMenuSelected,
|
Selected: m.styles.StatusMenuSelected,
|
||||||
Normal: m.styles.StatusMenuNormal,
|
Normal: m.styles.StatusMenuNormal,
|
||||||
})
|
}, label)
|
||||||
m.mode = ModeSync
|
m.mode = ModeSync
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +943,10 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
|||||||
m.setStatus("sync already running…")
|
m.setStatus("sync already running…")
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if m.remote == nil {
|
||||||
|
m.setError(fmt.Errorf("daemon client not available"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.clearStatus()
|
m.clearStatus()
|
||||||
m.syncing = true
|
m.syncing = true
|
||||||
switch action {
|
switch action {
|
||||||
@@ -919,17 +955,15 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
|||||||
default:
|
default:
|
||||||
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
|
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
|
||||||
}
|
}
|
||||||
store := m.store
|
remote := m.remote
|
||||||
timeStore := m.timeStore
|
|
||||||
projects := m.projects
|
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
var pull, push ytsync.Result
|
var pull, push ytsync.Result
|
||||||
var err error
|
var err error
|
||||||
switch action {
|
switch action {
|
||||||
case views.SyncActionPush:
|
case views.SyncActionPush:
|
||||||
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
push, err = remote.SyncPush(projectID)
|
||||||
default:
|
default:
|
||||||
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
pull, err = remote.SyncPull(projectID)
|
||||||
}
|
}
|
||||||
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
|
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1040,41 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.openSyncMenu(item.Project.ID)
|
m.openSyncMenu(item.Project.ID)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.MCPToken):
|
||||||
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
m.setError(fmt.Errorf("no project selected"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.remote == nil {
|
||||||
|
m.setError(fmt.Errorf("daemon client not available"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
token, _, err := m.remote.CreateMCPToken(item.Project.ID)
|
||||||
|
if err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.setStatus(fmt.Sprintf("MCP token for %s (save as .tm_token): project=%s token=%s", item.Project.ID, item.Project.ID, token))
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.MCPTokenRevoke):
|
||||||
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
m.setError(fmt.Errorf("no project selected"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.remote == nil {
|
||||||
|
m.setError(fmt.Errorf("daemon client not available"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := m.remote.RevokeMCPToken(item.Project.ID); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.setStatus(fmt.Sprintf("revoked MCP token for %s", item.Project.ID))
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.DeleteForce):
|
case key.Matches(msg, m.keys.DeleteForce):
|
||||||
m.clearStatus()
|
m.clearStatus()
|
||||||
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
@@ -1451,7 +1520,7 @@ func (m *Model) openProjects() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
yt[p.ID] = meta.YouTrack != nil && meta.YouTrack.Configured()
|
yt[p.ID] = meta.SyncConfigured()
|
||||||
}
|
}
|
||||||
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
|
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
|
||||||
return nil
|
return nil
|
||||||
@@ -1683,18 +1752,49 @@ func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) saveEditedTime(raw string) error {
|
func (m Model) updateTimeEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Escape):
|
||||||
|
m.mode = ModeTimeEntries
|
||||||
|
m.clearStatus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Tab), msg.Type == tea.KeyDown:
|
||||||
|
m.timeEdit.NextField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ShiftTab), msg.Type == tea.KeyUp:
|
||||||
|
m.timeEdit.PrevField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
m.clearStatus()
|
||||||
|
if err := m.saveEditedTime(); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = ModeTimeEntries
|
||||||
|
m.setStatus("time updated")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := m.timeEdit.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) saveEditedTime() error {
|
||||||
if m.timeStore == nil {
|
if m.timeStore == nil {
|
||||||
return fmt.Errorf("time store not configured")
|
return fmt.Errorf("time store not configured")
|
||||||
}
|
}
|
||||||
dur, err := ticket.ParseDurationFlexible(raw)
|
start, end, err := m.timeEdit.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
before := m.editingTimeEntry
|
before := m.editingTimeEntry
|
||||||
after := before
|
after := before
|
||||||
after.Duration = dur
|
after.Start = start
|
||||||
after.End = after.Start.Add(dur)
|
after.End = end
|
||||||
|
after.Duration = end.Sub(start)
|
||||||
if err := m.exec.Do(&command.UpdateTimeEntry{
|
if err := m.exec.Do(&command.UpdateTimeEntry{
|
||||||
Store: m.timeStore,
|
Store: m.timeStore,
|
||||||
Before: before,
|
Before: before,
|
||||||
|
|||||||
+53
-34
@@ -34,7 +34,7 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"enter", "select"},
|
{"enter", "select"},
|
||||||
{"n", "new project"},
|
{"n", "new project"},
|
||||||
{"e", "edit"},
|
{"e", "edit"},
|
||||||
{"Y", "YouTrack"},
|
{"Y", "Sync"},
|
||||||
{"x", "delete"},
|
{"x", "delete"},
|
||||||
{"X", "force delete"},
|
{"X", "force delete"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
@@ -87,13 +87,14 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
case ModeTimeEntries:
|
case ModeTimeEntries:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
{"e", "edit"},
|
{"e", "edit times"},
|
||||||
{"x", "delete"},
|
{"x", "delete"},
|
||||||
{"u/U", "undo/redo"},
|
{"u/U", "undo/redo"},
|
||||||
{"esc", "back"},
|
{"esc", "back"},
|
||||||
}
|
}
|
||||||
case ModeTimeEdit:
|
case ModeTimeEdit:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
|
{"tab/↑↓", "fields"},
|
||||||
{"enter", "save"},
|
{"enter", "save"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
@@ -121,7 +122,7 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"t/T", "timer/log"},
|
{"t/T", "timer/log"},
|
||||||
{"m", "entries"},
|
{"m", "entries"},
|
||||||
{"r", "report"},
|
{"r", "report"},
|
||||||
{"Y", "YouTrack"},
|
{"Y", "Sync"},
|
||||||
{"u/U", "undo/redo"},
|
{"u/U", "undo/redo"},
|
||||||
{"/", "search"},
|
{"/", "search"},
|
||||||
{"p", "project"},
|
{"p", "project"},
|
||||||
@@ -151,42 +152,60 @@ func (m Model) helpSep() string {
|
|||||||
return sep
|
return sep
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapHelpParts packs styled help snippets into lines that fit within width.
|
// padHelpCell pads a styled help snippet to cellW visible columns.
|
||||||
func wrapHelpParts(parts []string, width int, sep string) []string {
|
func padHelpCell(part string, cellW int) string {
|
||||||
|
pad := cellW - lipgloss.Width(part)
|
||||||
|
if pad < 0 {
|
||||||
|
pad = 0
|
||||||
|
}
|
||||||
|
return part + strings.Repeat(" ", pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableHelpParts lays out styled help snippets as a fixed-width column grid.
|
||||||
|
func tableHelpParts(parts []string, width int, sep string) []string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
if width < 1 {
|
if width < 1 {
|
||||||
width = 1
|
width = 1
|
||||||
}
|
}
|
||||||
sepW := lipgloss.Width(sep)
|
|
||||||
var lines []string
|
|
||||||
var cur string
|
|
||||||
|
|
||||||
|
cellW := 0
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
pw := lipgloss.Width(p)
|
if w := lipgloss.Width(p); w > cellW {
|
||||||
if cur == "" {
|
cellW = w
|
||||||
cur = p
|
}
|
||||||
// Oversized single entry still gets its own line so nothing is dropped.
|
}
|
||||||
if pw > width {
|
if cellW < 1 {
|
||||||
lines = append(lines, cur)
|
cellW = 1
|
||||||
cur = ""
|
}
|
||||||
|
|
||||||
|
sepW := lipgloss.Width(sep)
|
||||||
|
cols := 1
|
||||||
|
if cellW <= width {
|
||||||
|
cols = (width + sepW) / (cellW + sepW)
|
||||||
|
if cols < 1 {
|
||||||
|
cols = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for i := 0; i < len(parts); i += cols {
|
||||||
|
end := i + cols
|
||||||
|
if end > len(parts) {
|
||||||
|
end = len(parts)
|
||||||
|
}
|
||||||
|
row := parts[i:end]
|
||||||
|
cells := make([]string, len(row))
|
||||||
|
for j, p := range row {
|
||||||
|
// Pad every cell except a lone oversized entry on its own row.
|
||||||
|
if len(row) == 1 && lipgloss.Width(p) > width {
|
||||||
|
cells[j] = p
|
||||||
|
} else {
|
||||||
|
cells[j] = padHelpCell(p, cellW)
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if lipgloss.Width(cur)+sepW+pw <= width {
|
lines = append(lines, strings.Join(cells, sep))
|
||||||
cur = cur + sep + p
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, cur)
|
|
||||||
cur = p
|
|
||||||
if pw > width {
|
|
||||||
lines = append(lines, cur)
|
|
||||||
cur = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cur != "" {
|
|
||||||
lines = append(lines, cur)
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return []string{""}
|
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@@ -202,8 +221,8 @@ func (m Model) renderFooter() string {
|
|||||||
sep := m.helpSep()
|
sep := m.helpSep()
|
||||||
parts := m.helpParts()
|
parts := m.helpParts()
|
||||||
|
|
||||||
// Wrap help across the full width so all bindings stay visible.
|
// Table layout so key/desc columns stay aligned across rows.
|
||||||
lines := wrapHelpParts(parts, width, sep)
|
lines := tableHelpParts(parts, width, sep)
|
||||||
|
|
||||||
// Place version on the last line to the right when it fits; otherwise
|
// Place version on the last line to the right when it fits; otherwise
|
||||||
// put it on an extra right-aligned line.
|
// put it on an extra right-aligned line.
|
||||||
|
|||||||
+11
-1
@@ -25,6 +25,8 @@ type KeyMap struct {
|
|||||||
TimeEntries key.Binding // m — manage time entries
|
TimeEntries key.Binding // m — manage time entries
|
||||||
Report key.Binding // r — time report
|
Report key.Binding // r — time report
|
||||||
Sync key.Binding // Y — pull YouTrack issues
|
Sync key.Binding // Y — pull YouTrack issues
|
||||||
|
MCPToken key.Binding // g — create MCP token for project
|
||||||
|
MCPTokenRevoke key.Binding // G — revoke MCP token
|
||||||
Undo key.Binding
|
Undo key.Binding
|
||||||
Redo key.Binding
|
Redo key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
@@ -121,6 +123,14 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("Y"),
|
key.WithKeys("Y"),
|
||||||
key.WithHelp("Y", "YouTrack pull/push"),
|
key.WithHelp("Y", "YouTrack pull/push"),
|
||||||
),
|
),
|
||||||
|
MCPToken: key.NewBinding(
|
||||||
|
key.WithKeys("g"),
|
||||||
|
key.WithHelp("g", "MCP token"),
|
||||||
|
),
|
||||||
|
MCPTokenRevoke: key.NewBinding(
|
||||||
|
key.WithKeys("G"),
|
||||||
|
key.WithHelp("G", "revoke MCP token"),
|
||||||
|
),
|
||||||
Undo: key.NewBinding(
|
Undo: key.NewBinding(
|
||||||
key.WithKeys("u", "ctrl+z"),
|
key.WithKeys("u", "ctrl+z"),
|
||||||
key.WithHelp("u", "undo"),
|
key.WithHelp("u", "undo"),
|
||||||
@@ -154,7 +164,7 @@ func DefaultKeyMap() KeyMap {
|
|||||||
|
|
||||||
// ShortHelp implements help.KeyMap.
|
// ShortHelp implements help.KeyMap.
|
||||||
func (k KeyMap) ShortHelp() []key.Binding {
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.MCPToken, k.MCPTokenRevoke, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FullHelp implements help.KeyMap.
|
// FullHelp implements help.KeyMap.
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ func (m Model) renderBody(height int) string {
|
|||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||||
|
|
||||||
case ModeTimeEdit:
|
case ModeTimeEdit:
|
||||||
prompt := m.styles.InputPrompt.Render("Edit duration:")
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.timeEdit.View())
|
||||||
content := prompt + "\n" + m.input.View()
|
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
|
||||||
|
|
||||||
case ModeTimeEntries:
|
case ModeTimeEntries:
|
||||||
m.timeEntries.SetSize(m.width, height)
|
m.timeEntries.SetSize(m.width, height)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"task/internal/project"
|
"task/internal/project"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProjectEdit field indices.
|
// ProjectEdit field indices (backend picker is separate).
|
||||||
const (
|
const (
|
||||||
FieldRemoteURL = iota
|
FieldRemoteURL = iota
|
||||||
FieldToken
|
FieldToken
|
||||||
@@ -19,6 +19,12 @@ const (
|
|||||||
ProjectEditFieldCount
|
ProjectEditFieldCount
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var backendOptions = []string{
|
||||||
|
project.BackendNone,
|
||||||
|
project.BackendYouTrack,
|
||||||
|
project.BackendTaskMaster,
|
||||||
|
}
|
||||||
|
|
||||||
// ProjectEditStyles styles the project edit form.
|
// ProjectEditStyles styles the project edit form.
|
||||||
type ProjectEditStyles struct {
|
type ProjectEditStyles struct {
|
||||||
Label lipgloss.Style
|
Label lipgloss.Style
|
||||||
@@ -39,9 +45,12 @@ func DefaultProjectEditStyles() ProjectEditStyles {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectEditForm is a multi-field editor for YouTrack project settings.
|
// ProjectEditForm is a multi-field editor for sync backend settings.
|
||||||
type ProjectEditForm struct {
|
type ProjectEditForm struct {
|
||||||
ProjectID string
|
ProjectID string
|
||||||
|
Backend string
|
||||||
|
BackendIdx int
|
||||||
|
FocusBackend bool // true when selecting backend
|
||||||
Inputs []textinput.Model
|
Inputs []textinput.Model
|
||||||
Focus int
|
Focus int
|
||||||
Styles ProjectEditStyles
|
Styles ProjectEditStyles
|
||||||
@@ -67,34 +76,82 @@ func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectE
|
|||||||
return ti
|
return ti
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
backend := meta.EffectiveBackend()
|
||||||
inputs[FieldToken] = mk("permanent token", 512, true)
|
if backend == "" {
|
||||||
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
backend = project.BackendNone
|
||||||
|
}
|
||||||
|
bIdx := 0
|
||||||
|
for i, b := range backendOptions {
|
||||||
|
if b == backend {
|
||||||
|
bIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hadToken := false
|
hadToken := false
|
||||||
if meta.YouTrack != nil {
|
switch backend {
|
||||||
yt := meta.YouTrack
|
case project.BackendTaskMaster:
|
||||||
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
|
||||||
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
inputs[FieldToken] = mk("tm_… project token", 512, true)
|
||||||
if yt.Token != "" {
|
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
|
||||||
hadToken = true
|
if meta.TaskMaster != nil {
|
||||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
tm := meta.TaskMaster
|
||||||
|
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
|
||||||
|
inputs[FieldProjectKey].SetValue(tm.ProjectID)
|
||||||
|
if tm.Token != "" {
|
||||||
|
hadToken = true
|
||||||
|
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
||||||
|
inputs[FieldToken] = mk("permanent token", 512, true)
|
||||||
|
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
||||||
|
if meta.YouTrack != nil {
|
||||||
|
yt := meta.YouTrack
|
||||||
|
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
||||||
|
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
||||||
|
if yt.Token != "" {
|
||||||
|
hadToken = true
|
||||||
|
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
f := ProjectEditForm{
|
f := ProjectEditForm{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
Inputs: inputs,
|
Backend: backend,
|
||||||
Focus: FieldRemoteURL,
|
BackendIdx: bIdx,
|
||||||
Styles: styles,
|
FocusBackend: true,
|
||||||
Width: width,
|
Inputs: inputs,
|
||||||
hadToken: hadToken,
|
Focus: FieldRemoteURL,
|
||||||
|
Styles: styles,
|
||||||
|
Width: width,
|
||||||
|
hadToken: hadToken,
|
||||||
}
|
}
|
||||||
f.focusIndex(FieldRemoteURL)
|
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *ProjectEditForm) applyBackendPlaceholders() {
|
||||||
|
f.Backend = backendOptions[f.BackendIdx]
|
||||||
|
switch f.Backend {
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
|
||||||
|
f.Inputs[FieldToken].Placeholder = "tm_… project token"
|
||||||
|
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
|
||||||
|
case project.BackendYouTrack:
|
||||||
|
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
|
||||||
|
f.Inputs[FieldToken].Placeholder = "permanent token"
|
||||||
|
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
|
||||||
|
default:
|
||||||
|
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
|
||||||
|
f.Inputs[FieldToken].Placeholder = "(unused)"
|
||||||
|
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (f *ProjectEditForm) focusIndex(i int) {
|
func (f *ProjectEditForm) focusIndex(i int) {
|
||||||
|
f.FocusBackend = false
|
||||||
f.Focus = i
|
f.Focus = i
|
||||||
for idx := range f.Inputs {
|
for idx := range f.Inputs {
|
||||||
if idx == i {
|
if idx == i {
|
||||||
@@ -111,16 +168,52 @@ func (f *ProjectEditForm) focusIndex(i int) {
|
|||||||
|
|
||||||
// NextField moves focus to the next field.
|
// NextField moves focus to the next field.
|
||||||
func (f *ProjectEditForm) NextField() {
|
func (f *ProjectEditForm) NextField() {
|
||||||
|
if f.FocusBackend {
|
||||||
|
if f.Backend == project.BackendNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.focusIndex(FieldRemoteURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
|
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrevField moves focus to the previous field.
|
// PrevField moves focus to the previous field.
|
||||||
func (f *ProjectEditForm) PrevField() {
|
func (f *ProjectEditForm) PrevField() {
|
||||||
|
if f.FocusBackend {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.Focus == FieldRemoteURL {
|
||||||
|
f.blurInputs()
|
||||||
|
f.FocusBackend = true
|
||||||
|
return
|
||||||
|
}
|
||||||
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
|
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *ProjectEditForm) blurInputs() {
|
||||||
|
for idx := range f.Inputs {
|
||||||
|
f.Inputs[idx].Blur()
|
||||||
|
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
||||||
|
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CycleBackend changes the selected backend when focusing the picker.
|
||||||
|
func (f *ProjectEditForm) CycleBackend(delta int) {
|
||||||
|
if !f.FocusBackend {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := len(backendOptions)
|
||||||
|
f.BackendIdx = (f.BackendIdx + delta + n) % n
|
||||||
|
f.applyBackendPlaceholders()
|
||||||
|
}
|
||||||
|
|
||||||
// Update forwards a message to the focused text input.
|
// Update forwards a message to the focused text input.
|
||||||
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
if f.FocusBackend || f.Backend == project.BackendNone {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
||||||
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
|
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
|
||||||
@@ -131,60 +224,103 @@ func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
|||||||
|
|
||||||
// Meta builds the Meta to persist. Empty token keeps the previous token.
|
// Meta builds the Meta to persist. Empty token keeps the previous token.
|
||||||
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
|
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
|
||||||
|
backend := backendOptions[f.BackendIdx]
|
||||||
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
|
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
|
||||||
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
|
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
|
||||||
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
|
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
|
||||||
|
|
||||||
if baseURL == "" && token == "" && key == "" {
|
switch backend {
|
||||||
return project.Meta{YouTrack: nil}
|
case project.BackendNone:
|
||||||
|
return project.Meta{Backend: project.BackendNone}
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
|
||||||
|
if token == "" && previous.TaskMaster != nil {
|
||||||
|
tm.Token = previous.TaskMaster.Token
|
||||||
|
}
|
||||||
|
if baseURL == "" && token == "" && key == "" {
|
||||||
|
return project.Meta{Backend: project.BackendNone}
|
||||||
|
}
|
||||||
|
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
|
||||||
|
default:
|
||||||
|
yt := &project.YouTrack{BaseURL: baseURL, ProjectKey: key, Token: token}
|
||||||
|
if token == "" && previous.YouTrack != nil {
|
||||||
|
yt.Token = previous.YouTrack.Token
|
||||||
|
}
|
||||||
|
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
||||||
|
yt.Query = previous.YouTrack.Query
|
||||||
|
}
|
||||||
|
if baseURL == "" && token == "" && key == "" {
|
||||||
|
return project.Meta{Backend: project.BackendNone}
|
||||||
|
}
|
||||||
|
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
|
||||||
}
|
}
|
||||||
|
|
||||||
yt := &project.YouTrack{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
ProjectKey: key,
|
|
||||||
Token: token,
|
|
||||||
}
|
|
||||||
if token == "" && previous.YouTrack != nil {
|
|
||||||
yt.Token = previous.YouTrack.Token
|
|
||||||
}
|
|
||||||
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
|
||||||
yt.Query = previous.YouTrack.Query
|
|
||||||
}
|
|
||||||
return project.Meta{YouTrack: yt}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ensures partial YouTrack configs are rejected.
|
// Validate ensures partial configs are rejected.
|
||||||
func (f ProjectEditForm) Validate(previous project.Meta) error {
|
func (f ProjectEditForm) Validate(previous project.Meta) error {
|
||||||
meta := f.Meta(previous)
|
meta := f.Meta(previous)
|
||||||
if meta.YouTrack == nil {
|
switch meta.EffectiveBackend() {
|
||||||
|
case project.BackendNone:
|
||||||
|
return nil
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
tm := meta.TaskMaster
|
||||||
|
if tm == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !tm.Configured() {
|
||||||
|
return fmt.Errorf("incomplete taskmaster config: need base URL, token, project id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
yt := meta.YouTrack
|
||||||
|
if yt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !yt.Configured() {
|
||||||
|
return fmt.Errorf("incomplete YouTrack config: need remote URL, token, project key")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
yt := meta.YouTrack
|
|
||||||
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != ""
|
|
||||||
if any && !yt.Configured() {
|
|
||||||
missing := []string{}
|
|
||||||
if yt.BaseURL == "" {
|
|
||||||
missing = append(missing, "remote URL")
|
|
||||||
}
|
|
||||||
if yt.Token == "" {
|
|
||||||
missing = append(missing, "token")
|
|
||||||
}
|
|
||||||
if yt.ProjectKey == "" {
|
|
||||||
missing = append(missing, "project key")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// View renders the form.
|
// View renders the form.
|
||||||
func (f ProjectEditForm) View() string {
|
func (f ProjectEditForm) View() string {
|
||||||
labels := []string{"Remote URL", "Token", "Project key"}
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
|
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel"))
|
b.WriteString(f.Styles.Hint.Render("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
backendLine := "Backend: "
|
||||||
|
for i, opt := range backendOptions {
|
||||||
|
label := opt
|
||||||
|
if i == f.BackendIdx {
|
||||||
|
if f.FocusBackend {
|
||||||
|
label = f.Styles.Focused.Render("[" + opt + "]")
|
||||||
|
} else {
|
||||||
|
label = f.Styles.Focused.Render(opt)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
label = f.Styles.Blurred.Render(opt)
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
backendLine += " "
|
||||||
|
}
|
||||||
|
backendLine += label
|
||||||
|
}
|
||||||
|
b.WriteString(backendLine)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if f.Backend == project.BackendNone {
|
||||||
|
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
|
||||||
|
b.WriteString("\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := []string{"Remote URL", "Token", "Project key"}
|
||||||
|
if f.Backend == project.BackendTaskMaster {
|
||||||
|
labels = []string{"Server URL", "Project token", "Remote project ID"}
|
||||||
|
}
|
||||||
for i, label := range labels {
|
for i, label := range labels {
|
||||||
b.WriteString(f.Styles.Label.Render(label + ":"))
|
b.WriteString(f.Styles.Label.Render(label + ":"))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
func TestProjectEditMetaKeepsToken(t *testing.T) {
|
func TestProjectEditMetaKeepsToken(t *testing.T) {
|
||||||
prev := project.Meta{
|
prev := project.Meta{
|
||||||
|
Backend: project.BackendYouTrack,
|
||||||
YouTrack: &project.YouTrack{
|
YouTrack: &project.YouTrack{
|
||||||
BaseURL: "https://yt.example",
|
BaseURL: "https://yt.example",
|
||||||
Token: "secret",
|
Token: "secret",
|
||||||
@@ -31,10 +32,27 @@ func TestProjectEditMetaKeepsToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProjectEditValidateIncomplete(t *testing.T) {
|
func TestProjectEditValidateIncomplete(t *testing.T) {
|
||||||
f := views.NewProjectEditForm("inbox", project.Meta{}, 80)
|
f := views.NewProjectEditForm("inbox", project.Meta{Backend: project.BackendYouTrack}, 80)
|
||||||
|
f.BackendIdx = 1 // youtrack
|
||||||
|
f.Backend = project.BackendYouTrack
|
||||||
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
||||||
// missing token + key
|
// missing token + key
|
||||||
if err := f.Validate(project.Meta{}); err == nil {
|
if err := f.Validate(project.Meta{}); err == nil {
|
||||||
t.Fatal("expected incomplete error")
|
t.Fatal("expected incomplete error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectEditTaskMaster(t *testing.T) {
|
||||||
|
prev := project.Meta{
|
||||||
|
Backend: project.BackendTaskMaster,
|
||||||
|
TaskMaster: &project.TaskMaster{
|
||||||
|
BaseURL: "http://127.0.0.1:8080", Token: "tm_secret", ProjectID: "abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := views.NewProjectEditForm("inbox", prev, 80)
|
||||||
|
f.Inputs[views.FieldToken].SetValue("")
|
||||||
|
meta := f.Meta(prev)
|
||||||
|
if meta.TaskMaster == nil || meta.TaskMaster.Token != "tm_secret" {
|
||||||
|
t.Fatalf("expected kept tm token, got %+v", meta.TaskMaster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncAction is a YouTrack sync menu choice.
|
// SyncAction is a sync menu choice.
|
||||||
type SyncAction string
|
type SyncAction string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -21,27 +21,30 @@ type SyncOption struct {
|
|||||||
Shortcut rune
|
Shortcut rune
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllSyncOptions is the ordered sync menu.
|
|
||||||
var AllSyncOptions = []SyncOption{
|
|
||||||
{Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'},
|
|
||||||
{Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'},
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncMenu renders a pull/push picker overlay.
|
// SyncMenu renders a pull/push picker overlay.
|
||||||
type SyncMenu struct {
|
type SyncMenu struct {
|
||||||
Cursor int
|
Cursor int
|
||||||
Width int
|
Width int
|
||||||
Styles StatusMenuStyles
|
Title string
|
||||||
|
Options []SyncOption
|
||||||
|
Styles StatusMenuStyles
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncMenu creates a sync action menu.
|
// NewSyncMenu creates a sync action menu for the given backend label.
|
||||||
func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu {
|
func NewSyncMenu(width int, styles StatusMenuStyles, backendLabel string) SyncMenu {
|
||||||
return SyncMenu{Cursor: 0, Width: width, Styles: styles}
|
if backendLabel == "" {
|
||||||
|
backendLabel = "Sync"
|
||||||
|
}
|
||||||
|
opts := []SyncOption{
|
||||||
|
{Action: SyncActionPull, Label: "Pull from " + backendLabel, Shortcut: 'p'},
|
||||||
|
{Action: SyncActionPush, Label: "Push to " + backendLabel, Shortcut: 'u'},
|
||||||
|
}
|
||||||
|
return SyncMenu{Cursor: 0, Width: width, Title: backendLabel, Options: opts, Styles: styles}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveDown moves the selection down.
|
// MoveDown moves the selection down.
|
||||||
func (m *SyncMenu) MoveDown() {
|
func (m *SyncMenu) MoveDown() {
|
||||||
if m.Cursor < len(AllSyncOptions)-1 {
|
if m.Cursor < len(m.Options)-1 {
|
||||||
m.Cursor++
|
m.Cursor++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +59,7 @@ func (m *SyncMenu) MoveUp() {
|
|||||||
// JumpToShortcut selects the option whose shortcut matches r.
|
// JumpToShortcut selects the option whose shortcut matches r.
|
||||||
func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
||||||
r = unicode.ToLower(r)
|
r = unicode.ToLower(r)
|
||||||
for i, opt := range AllSyncOptions {
|
for i, opt := range m.Options {
|
||||||
if opt.Shortcut == r {
|
if opt.Shortcut == r {
|
||||||
m.Cursor = i
|
m.Cursor = i
|
||||||
return true
|
return true
|
||||||
@@ -67,17 +70,17 @@ func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
|||||||
|
|
||||||
// Selected returns the highlighted action.
|
// Selected returns the highlighted action.
|
||||||
func (m SyncMenu) Selected() SyncAction {
|
func (m SyncMenu) Selected() SyncAction {
|
||||||
if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) {
|
if m.Cursor < 0 || m.Cursor >= len(m.Options) {
|
||||||
return SyncActionPull
|
return SyncActionPull
|
||||||
}
|
}
|
||||||
return AllSyncOptions[m.Cursor].Action
|
return m.Options[m.Cursor].Action
|
||||||
}
|
}
|
||||||
|
|
||||||
// View renders the sync menu.
|
// View renders the sync menu.
|
||||||
func (m SyncMenu) View() string {
|
func (m SyncMenu) View() string {
|
||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, m.Styles.Title.Render("YouTrack"))
|
lines = append(lines, m.Styles.Title.Render(m.Title))
|
||||||
for i, opt := range AllSyncOptions {
|
for i, opt := range m.Options {
|
||||||
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
|
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
|
||||||
if i == m.Cursor {
|
if i == m.Cursor {
|
||||||
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
|||||||
|
|
||||||
badge := l.statusBadge(t.Status)
|
badge := l.statusBadge(t.Status)
|
||||||
ext := ""
|
ext := ""
|
||||||
if t.ExternalID != "" {
|
if id := t.DisplayID(); id != "" {
|
||||||
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
|
ext = lipgloss.NewStyle().Faint(true).Render(id) + " "
|
||||||
}
|
}
|
||||||
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
|
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
|
||||||
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
|
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"task/internal/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Time edit field indices.
|
||||||
|
const (
|
||||||
|
FieldTimeStart = iota
|
||||||
|
FieldTimeEnd
|
||||||
|
TimeEditFieldCount
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeEditLayout = "2006-01-02 15:04"
|
||||||
|
|
||||||
|
// TimeEditStyles styles the time-entry edit form.
|
||||||
|
type TimeEditStyles struct {
|
||||||
|
Label lipgloss.Style
|
||||||
|
Focused lipgloss.Style
|
||||||
|
Blurred lipgloss.Style
|
||||||
|
Hint lipgloss.Style
|
||||||
|
Title lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTimeEditStyles returns sensible defaults.
|
||||||
|
func DefaultTimeEditStyles() TimeEditStyles {
|
||||||
|
return TimeEditStyles{
|
||||||
|
Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||||||
|
Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")),
|
||||||
|
Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||||
|
Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||||
|
Title: lipgloss.NewStyle().Bold(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeEditForm edits start and end of a time entry.
|
||||||
|
type TimeEditForm struct {
|
||||||
|
Inputs []textinput.Model
|
||||||
|
Focus int
|
||||||
|
Styles TimeEditStyles
|
||||||
|
Width int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTimeEditForm builds a form prefilled from an entry.
|
||||||
|
func NewTimeEditForm(e ticket.TimeEntry, width int) TimeEditForm {
|
||||||
|
styles := DefaultTimeEditStyles()
|
||||||
|
inputs := make([]textinput.Model, TimeEditFieldCount)
|
||||||
|
|
||||||
|
mk := func(placeholder string) textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = placeholder
|
||||||
|
ti.CharLimit = 16
|
||||||
|
ti.Width = max(20, width-10)
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs[FieldTimeStart] = mk(timeEditLayout)
|
||||||
|
inputs[FieldTimeEnd] = mk(timeEditLayout)
|
||||||
|
if !e.Start.IsZero() {
|
||||||
|
inputs[FieldTimeStart].SetValue(e.Start.Local().Format(timeEditLayout))
|
||||||
|
}
|
||||||
|
if !e.End.IsZero() {
|
||||||
|
inputs[FieldTimeEnd].SetValue(e.End.Local().Format(timeEditLayout))
|
||||||
|
} else if !e.Start.IsZero() && e.Duration > 0 {
|
||||||
|
inputs[FieldTimeEnd].SetValue(e.Start.Add(e.Duration).Local().Format(timeEditLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
f := TimeEditForm{
|
||||||
|
Inputs: inputs,
|
||||||
|
Focus: FieldTimeStart,
|
||||||
|
Styles: styles,
|
||||||
|
Width: width,
|
||||||
|
}
|
||||||
|
f.focusIndex(FieldTimeStart)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *TimeEditForm) focusIndex(i int) {
|
||||||
|
f.Focus = i
|
||||||
|
for idx := range f.Inputs {
|
||||||
|
if idx == i {
|
||||||
|
f.Inputs[idx].Focus()
|
||||||
|
f.Inputs[idx].PromptStyle = f.Styles.Focused
|
||||||
|
f.Inputs[idx].TextStyle = f.Styles.Focused
|
||||||
|
} else {
|
||||||
|
f.Inputs[idx].Blur()
|
||||||
|
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
||||||
|
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextField moves focus to the next field.
|
||||||
|
func (f *TimeEditForm) NextField() {
|
||||||
|
f.focusIndex((f.Focus + 1) % TimeEditFieldCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrevField moves focus to the previous field.
|
||||||
|
func (f *TimeEditForm) PrevField() {
|
||||||
|
f.focusIndex((f.Focus - 1 + TimeEditFieldCount) % TimeEditFieldCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update forwards a message to the focused text input.
|
||||||
|
func (f *TimeEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse returns start and end in UTC, or an error.
|
||||||
|
func (f TimeEditForm) Parse() (start, end time.Time, err error) {
|
||||||
|
startRaw := strings.TrimSpace(f.Inputs[FieldTimeStart].Value())
|
||||||
|
endRaw := strings.TrimSpace(f.Inputs[FieldTimeEnd].Value())
|
||||||
|
if startRaw == "" || endRaw == "" {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("start and end are required")
|
||||||
|
}
|
||||||
|
start, err = time.ParseInLocation(timeEditLayout, startRaw, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("start: use %s", timeEditLayout)
|
||||||
|
}
|
||||||
|
end, err = time.ParseInLocation(timeEditLayout, endRaw, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("end: use %s", timeEditLayout)
|
||||||
|
}
|
||||||
|
if !end.After(start) {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
|
||||||
|
}
|
||||||
|
return start.UTC(), end.UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the form.
|
||||||
|
func (f TimeEditForm) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(f.Styles.Title.Render("Edit time entry"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
labels := []string{"Start", "End"}
|
||||||
|
for i, label := range labels {
|
||||||
|
b.WriteString(f.Styles.Label.Render(label+":"))
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(f.Inputs[i].View())
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(f.Styles.Hint.Render("format " + timeEditLayout + " · tab fields · enter save · esc cancel"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"task/internal/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTimeEditFormParse(t *testing.T) {
|
||||||
|
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
|
||||||
|
end := start.Add(90 * time.Minute)
|
||||||
|
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: end, Duration: 90 * time.Minute}, 80)
|
||||||
|
|
||||||
|
gotStart, gotEnd, err := f.Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !gotStart.Equal(start.UTC()) {
|
||||||
|
t.Fatalf("start: got %v want %v", gotStart, start.UTC())
|
||||||
|
}
|
||||||
|
if !gotEnd.Equal(end.UTC()) {
|
||||||
|
t.Fatalf("end: got %v want %v", gotEnd, end.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeEditFormRejectsBadRange(t *testing.T) {
|
||||||
|
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
|
||||||
|
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: start, Duration: time.Minute}, 80)
|
||||||
|
f.Inputs[FieldTimeEnd].SetValue(start.Format(timeEditLayout))
|
||||||
|
if _, _, err := f.Parse(); err == nil {
|
||||||
|
t.Fatal("expected error for end == start")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ func (v TimeEntriesView) View() string {
|
|||||||
title = "ticket"
|
title = "ticket"
|
||||||
}
|
}
|
||||||
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
|
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
|
||||||
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
|
hint := v.Styles.Hint.Render("j/k navigate · e edit start/end · x delete · esc back")
|
||||||
|
|
||||||
innerW := max(20, v.Width-2)
|
innerW := max(20, v.Width-2)
|
||||||
var lines []string
|
var lines []string
|
||||||
|
|||||||
Vendored
@@ -0,0 +1,18 @@
|
|||||||
|
package webembed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var dist embed.FS
|
||||||
|
|
||||||
|
// FS returns the embedded static frontend filesystem (contents of dist/).
|
||||||
|
func FS() fs.FS {
|
||||||
|
sub, err := fs.Sub(dist, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return dist
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
@@ -516,6 +516,49 @@ func (c *Client) findProjectViaIssue(ctx context.Context, shortName string) (str
|
|||||||
return issues[0].Project.ID, nil
|
return issues[0].Project.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Project is a YouTrack project summary for the register/import UI.
|
||||||
|
type Project struct {
|
||||||
|
ID string
|
||||||
|
ShortName string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjects lists admin-accessible projects (requires sufficient token scope).
|
||||||
|
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
|
||||||
|
if err := c.ensureAuth(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var all []Project
|
||||||
|
for page := 0; page < maxPages; page++ {
|
||||||
|
skip := page * defaultPageSize
|
||||||
|
u, err := url.Parse(c.base() + "/api/admin/projects")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("fields", "id,shortName,name")
|
||||||
|
q.Set("$top", fmt.Sprintf("%d", defaultPageSize))
|
||||||
|
q.Set("$skip", fmt.Sprintf("%d", skip))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
var raw []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ShortName string `json:"shortName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range raw {
|
||||||
|
all = append(all, Project{ID: p.ID, ShortName: p.ShortName, Name: p.Name})
|
||||||
|
}
|
||||||
|
if len(raw) < defaultPageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IssueURL builds the browser URL for an issue.
|
// IssueURL builds the browser URL for an issue.
|
||||||
func (c *Client) IssueURL(idReadable string) string {
|
func (c *Client) IssueURL(idReadable string) string {
|
||||||
return c.base() + "/issue/" + idReadable
|
return c.base() + "/issue/" + idReadable
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Svelte + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Svelte in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
|
## Need an official Svelte framework?
|
||||||
|
|
||||||
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `checkJs` in the JS template?**
|
||||||
|
|
||||||
|
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.js
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>task-master</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user