Compare commits
23
Commits
81f2902ff1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846bb31dc9 | ||
|
|
2dbf0918de | ||
|
|
98fcb57298 | ||
|
|
229f7a2fe1 | ||
|
|
69c6747a31 | ||
|
|
efeb4a74a8 | ||
|
|
26a3723a06 | ||
|
|
6e6b13270a | ||
|
|
614cb94b46 | ||
|
|
f97856b8a6 | ||
|
|
34ab3b9c5b | ||
|
|
2061df63d4 | ||
|
|
a594183310 | ||
|
|
b311a355fd | ||
|
|
79b0821837 | ||
|
|
afbf86dcf2 | ||
|
|
925bedf259 | ||
|
|
472a2fa72e | ||
|
|
74066649e4 | ||
|
|
4adfcdb95a | ||
|
|
98d2253f44 | ||
|
|
19ae1f1b62 | ||
|
|
214ae1e0cf |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tm": {
|
||||||
|
"command": "${workspaceFolder}/bin/tm",
|
||||||
|
"args": ["mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: CI
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:latest
|
||||||
|
options: --network host
|
||||||
|
steps:
|
||||||
|
# Node-Actions (checkout / upload-artifact) brauchen node im Container
|
||||||
|
- name: Install Node.js
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends nodejs npm
|
||||||
|
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: List files in the repository
|
||||||
|
run: ls ${{ gitea.workspace }}
|
||||||
|
|
||||||
|
- name: Download modules
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: make test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: make build
|
||||||
|
|
||||||
|
- name: Build server
|
||||||
|
run: make build-server
|
||||||
|
|
||||||
|
- name: Version
|
||||||
|
run: make version
|
||||||
|
|
||||||
|
- name: Upload binary
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: tm
|
||||||
|
path: |
|
||||||
|
./bin/tm
|
||||||
|
./bin/tm_server
|
||||||
|
|
||||||
|
- run: echo "This job's status is ${{ job.status }}."
|
||||||
|
|
||||||
|
demo:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test-and-build
|
||||||
|
container:
|
||||||
|
image: ghcr.io/charmbracelet/vhs:latest
|
||||||
|
options: --network host
|
||||||
|
steps:
|
||||||
|
- name: Install Node.js and vim
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends nodejs npm vim
|
||||||
|
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download binary
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: tm
|
||||||
|
path: ./bin
|
||||||
|
|
||||||
|
- name: Make binary executable
|
||||||
|
run: chmod +x ./bin/tm
|
||||||
|
|
||||||
|
- name: Record demo
|
||||||
|
run: vhs demo.tape
|
||||||
|
|
||||||
|
- name: Upload demo
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: demo
|
||||||
|
path: |
|
||||||
|
demo.gif
|
||||||
|
demo.mp4
|
||||||
+12
-1
@@ -1,8 +1,19 @@
|
|||||||
/bin/
|
/bin/
|
||||||
.dev-home/
|
.dev-home/
|
||||||
|
.tm_token
|
||||||
|
**/.tm_token
|
||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
demo.gif
|
# 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,5 +1,7 @@
|
|||||||
BINARY := tm
|
BINARY := tm
|
||||||
|
BINARY_SERVER := tm_server
|
||||||
CMD := ./cmd/task-master
|
CMD := ./cmd/task-master
|
||||||
|
CMD_SERVER := ./cmd/tm-server
|
||||||
VERSION_PKG := task/internal/version
|
VERSION_PKG := task/internal/version
|
||||||
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# task-master
|
||||||
|
|
||||||
|
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea). Dazu der optionale Web-Server `tm_server` mit Svelte-UI und SQLite.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> Demo wird aus [`demo.tape`](demo.tape) erzeugt (`make demo`). Ohne GIF lokal: siehe [Demo aufnehmen](#demo-aufnehmen).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**TUI (`tm`)**
|
||||||
|
|
||||||
|
- Projekte & Tickets lokal als JSON hinter einem Hintergrund-Daemon (Unix-Socket)
|
||||||
|
- Mehrere TUI-Instanzen teilen denselben State; `tm stop` beendet den Daemon
|
||||||
|
- Board-Filter: All / Backlog / In Progress / Done
|
||||||
|
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
|
||||||
|
- Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren
|
||||||
|
- Report mit Group-by (Ticket / Tag)
|
||||||
|
- Undo / Redo über eine Command-History (im Daemon)
|
||||||
|
- Vim-Style Keybindings
|
||||||
|
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
|
||||||
|
- Sync gegen `tm_server` (Backend `taskmaster`, Projekt-Tokens)
|
||||||
|
- MCP-Token pro Projekt erzeugen (`g` in der Projektliste) für späteren KI-Zugriff
|
||||||
|
|
||||||
|
**Web (`tm_server`)**
|
||||||
|
|
||||||
|
- Eingebettetes Svelte-UI, SQLite, Auth und Rechte pro Projekt
|
||||||
|
- Projektgruppen, Sidebar-Navigation, Hash-Routes
|
||||||
|
- Drag & Drop auf dem Board (OPEN / WIP / DONE)
|
||||||
|
- Lesbare Ticket-Keys aus Projektcode + Sequenz (z. B. `WEB-12`)
|
||||||
|
- Visual- und Markdown-Editor für Ticket-Beschreibungen
|
||||||
|
- Datei-Anhänge; Tabellen (XLSX/ODS/…) im Browser editierbar
|
||||||
|
- Report mit Datums- und Ticket-Filtern sowie Eintragsliste
|
||||||
|
- Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
|
||||||
|
- Members, Settings (Tokens), Users (Admin)
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Voraussetzung: Go (siehe `go.mod`). Für `tm_server` zusätzlich Node.js (Frontend-Build).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.aloxdrim.de:skruecken/task-master.git
|
||||||
|
cd task-master
|
||||||
|
make install # → $(go env GOPATH)/bin/tm
|
||||||
|
# oder nur bauen:
|
||||||
|
make build # → ./bin/tm
|
||||||
|
make build-server # Frontend + → ./bin/tm_server
|
||||||
|
make build-all # beides
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tm # TUI starten (startet Daemon bei Bedarf)
|
||||||
|
tm sync pull # Issues von YouTrack / tm_server holen
|
||||||
|
tm sync push # lokale Änderungen pushen
|
||||||
|
tm sync pull -p inbox # anderes Projekt
|
||||||
|
tm status # Daemon-Status + Socket-Pfad
|
||||||
|
tm stop # Hintergrund-Daemon beenden
|
||||||
|
tm daemon # Daemon im Vordergrund (Debug)
|
||||||
|
tm version # Build-Version (Git-Hash)
|
||||||
|
tm help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lokaler JSON-Daemon
|
||||||
|
|
||||||
|
`tm` speichert weiterhin JSON unter dem DataDir, schreibt aber nur noch über einen lokalen Daemon-Prozess (eine Instanz pro DataDir). Socket/PID liegen unter `$XDG_RUNTIME_DIR/task-master-<hash>/` (Fallback: Temp-Dir). Die TUI und `tm sync` verbinden sich als vertrauenswürdige Clients (`X-TM-Client: tui|cli`).
|
||||||
|
|
||||||
|
### MCP (`tm mcp`)
|
||||||
|
|
||||||
|
1. In der TUI-Projektliste `g` → Token erzeugen (Klartext einmal in der Statuszeile); `G` widerruft.
|
||||||
|
2. Im Repo-Root (oder darüber) `.tm_token` anlegen — **nicht committen**:
|
||||||
|
|
||||||
|
```
|
||||||
|
project=inbox
|
||||||
|
token=tm_mcp_…
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Cursor / MCP-Client:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "command": "tm", "args": ["mcp"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne gültiges `.tm_token` macht der MCP-Server keine Daemon-Aufrufe. Mit Token sind Lesen und Schreiben nur für dieses eine TM-Projekt erlaubt.
|
||||||
|
|
||||||
|
### Web-Server (`tm_server`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekte + verdrahtet TUI
|
||||||
|
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
|
||||||
|
make run-dev # TUI: Projekte „Test“ + „Website Relaunch“ schon mit Token connected
|
||||||
|
|
||||||
|
# Produktion / manuell:
|
||||||
|
export TM_ADMIN_USER=admin
|
||||||
|
export TM_ADMIN_PASSWORD='change-me'
|
||||||
|
export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
|
||||||
|
./bin/tm_server -listen :8080
|
||||||
|
|
||||||
|
# JSON-Daten der TUI einmalig importieren:
|
||||||
|
./bin/tm_server import-json -owner admin
|
||||||
|
```
|
||||||
|
|
||||||
|
UI: http://localhost:8080 — Dashboard, Board, Report, Attachments, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
|
||||||
|
|
||||||
|
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
|
||||||
|
Beim Seed (`TM_SEED_DEMO=1`) werden Demo-Projekte angelegt und für **Test** sowie **Website Relaunch** Write-Tokens unter `.dev-home` als lokale TUI-Projekte (`backend=taskmaster`) hinterlegt — danach `make run-dev` und Sync (`Y` → Pull). Login: `admin`/`admin` (Member: `alice`/`alice`).
|
||||||
|
|
||||||
|
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`. Frontend neu bauen ohne Go-Rebuild: `make web-build`.
|
||||||
|
|
||||||
|
Sandbox ohne echte Config/Daten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-dev # nutzt ./.dev-home
|
||||||
|
# oder:
|
||||||
|
TASK_MASTER_HOME=/tmp/tm-sandbox tm
|
||||||
|
```
|
||||||
|
|
||||||
|
Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~/.config/task-master`.
|
||||||
|
|
||||||
|
| Variable | Wirkung |
|
||||||
|
|------------------------|---------------------------------------------------|
|
||||||
|
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
|
||||||
|
| `TASK_MASTER_DATA_DIR` | Nur Datenverzeichnis überschreiben |
|
||||||
|
| `TASK_MASTER_SECRET` | Verschlüsselt Remote-Tokens in SQLite |
|
||||||
|
| `TM_ADMIN_USER` / `TM_ADMIN_PASSWORD` | Bootstrap des ersten Admins |
|
||||||
|
| `TM_SEED_DEMO=1` | Demo-Projekte + TUI-Verdrahtung (Test / Website Relaunch) |
|
||||||
|
| `TM_SEED_BASE_URL` | Basis-URL in den geseedeten TUI-`meta.json` |
|
||||||
|
|
||||||
|
Server-Flags: `-listen`, `-db`, `-secure` (siehe `tm_server help`).
|
||||||
|
|
||||||
|
## Keybindings (Auszug)
|
||||||
|
|
||||||
|
| Taste | Aktion |
|
||||||
|
|---------|------------------------|
|
||||||
|
| `j`/`k` | Navigieren |
|
||||||
|
| `n` | Neues Ticket/Projekt |
|
||||||
|
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack / Sync-Backend) |
|
||||||
|
| `s` | Status ändern |
|
||||||
|
| `l` | Board-Filter |
|
||||||
|
| `/` | Suche |
|
||||||
|
| `p`/`P` | Projekt / Ticket verschieben |
|
||||||
|
| `Y` | Sync-Menü: Pull / Push |
|
||||||
|
| `t`/`T` | Timer / Zeit buchen |
|
||||||
|
| `m` | Zeiteinträge |
|
||||||
|
| `r` | Report |
|
||||||
|
| `x`/`X` | Löschen / Force-Delete |
|
||||||
|
| `u`/`U` | Undo / Redo |
|
||||||
|
| `q` | Beenden |
|
||||||
|
|
||||||
|
Checklisten im Preview: `J`/`K` + `Space`.
|
||||||
|
|
||||||
|
### YouTrack / Sync
|
||||||
|
|
||||||
|
1. `p` → Projekt wählen → `e`
|
||||||
|
2. Backend wählen: YouTrack (Remote-URL, Permanent Token, Project-Key) oder task-master (Server-URL + Projekt-Token)
|
||||||
|
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
|
||||||
|
4. `Y` → **Pull** oder **Push**
|
||||||
|
CLI: `tm sync pull` / `tm sync push`
|
||||||
|
|
||||||
|
Zeit: lokale Einträge ohne `externalId` werden remote als Work-Items gepusht; remote Work Items landen lokal beim Pull.
|
||||||
|
|
||||||
|
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
make build
|
||||||
|
make build-server # braucht Node (npm ci + Vite-Build)
|
||||||
|
make version
|
||||||
|
```
|
||||||
|
|
||||||
|
CI (Gitea): Tests, `tm` + `tm_server`-Build, Demo-GIF als Artifact.
|
||||||
|
|
||||||
|
## Demo aufnehmen
|
||||||
|
|
||||||
|
Die Walkthrough-Aufnahme steckt in [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# einmalig: vhs + ttyd/ffmpeg (siehe VHS-Docs)
|
||||||
|
make demo # schreibt demo.gif (+ demo.mp4)
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Pipeline erzeugt das GIF ebenfalls und lädt es als Artifact hoch.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Privat / eigener Use — siehe Repository-Einstellungen auf Gitea.
|
||||||
+206
-16
@@ -1,15 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -23,6 +26,36 @@ func main() {
|
|||||||
case "help", "--help", "-h":
|
case "help", "--help", "-h":
|
||||||
printHelp()
|
printHelp()
|
||||||
return
|
return
|
||||||
|
case "sync":
|
||||||
|
if err := runSync(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "daemon":
|
||||||
|
if err := runDaemon(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "stop":
|
||||||
|
if err := runStop(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "status":
|
||||||
|
if err := runStatus(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "mcp":
|
||||||
|
if err := runMCP(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
default:
|
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()
|
||||||
@@ -36,36 +69,189 @@ 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 runDaemon() error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return daemon.Run(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStop() error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := daemon.Stop(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("daemon stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus() error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := daemon.StatusOf(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state := "stopped"
|
||||||
|
if st.Running {
|
||||||
|
state = "running"
|
||||||
|
}
|
||||||
|
fmt.Printf("status: %s\n", state)
|
||||||
|
fmt.Printf("socket: %s\n", st.Socket)
|
||||||
|
fmt.Printf("dataDir: %s\n", st.DataDir)
|
||||||
|
if st.PID > 0 {
|
||||||
|
fmt.Printf("pid: %d\n", st.PID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSync(args []string) error {
|
||||||
|
cfg, cli, err := ensureClient(api.ClientCLI)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID := cfg.ActiveProject
|
||||||
|
action := ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "pull", "push":
|
||||||
|
if action != "" {
|
||||||
|
return fmt.Errorf("specify only one of pull/push")
|
||||||
|
}
|
||||||
|
action = args[i]
|
||||||
|
case "--project", "-p":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return fmt.Errorf("%s requires a project id", args[i])
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
projectID = strings.TrimSpace(args[i])
|
||||||
|
case "--help", "-h":
|
||||||
|
fmt.Printf("Usage: %s sync <pull|push> [--project ID]\n", config.CliName)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown sync argument: %s (use pull or push)", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == "" {
|
||||||
|
return fmt.Errorf("usage: %s sync <pull|push> [--project ID]", config.CliName)
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
return fmt.Errorf("project id required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "push" {
|
||||||
|
out, err := cli.SyncPush(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
|
||||||
|
projectID, out.Created, out.Updated, out.TimePushed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := cli.SyncPull(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
|
||||||
|
projectID, out.Created, out.Updated, out.TimePulled, out.Total)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMCP() error {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tf, err := mcpserver.FindTokenFile(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
paths, err := daemon.Ensure(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("daemon: %w", err)
|
||||||
|
}
|
||||||
|
cli := client.Dial(client.Options{
|
||||||
|
Socket: paths.Socket,
|
||||||
|
Kind: api.ClientMCP,
|
||||||
|
Token: tf.Token,
|
||||||
|
Project: tf.Project,
|
||||||
|
})
|
||||||
|
return mcpserver.ServeStdio(cli, tf)
|
||||||
|
}
|
||||||
|
|
||||||
func printHelp() {
|
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 via daemon from configured backend
|
||||||
|
%s sync push [-p ID] Push via daemon to configured backend
|
||||||
|
%s daemon Run the JSON daemon in the foreground
|
||||||
|
%s stop Stop the background daemon
|
||||||
|
%s status Show daemon status and socket path
|
||||||
|
%s mcp MCP stdio server (requires .tm_token)
|
||||||
%s version Print build version
|
%s version Print build version
|
||||||
%s help Show this help
|
%s help Show this help
|
||||||
|
|
||||||
@@ -73,5 +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
|
||||||
|
|
||||||
`, config.CliName, config.CliName, config.CliName, config.CliName)
|
The TUI talks to a per-DataDir background daemon over a Unix socket so multiple
|
||||||
|
instances stay consistent. Create an MCP token from the project list (key g);
|
||||||
|
place project= and token= in a repo .tm_token file, then run: tm mcp
|
||||||
|
|
||||||
|
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"task/internal/config"
|
||||||
|
"task/internal/server"
|
||||||
|
"task/internal/sqlite"
|
||||||
|
"task/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := flag.String("listen", ":8080", "HTTP listen address")
|
||||||
|
dbPath := flag.String("db", "", "SQLite database path (default: <dataDir>/task-master.db)")
|
||||||
|
secure := flag.Bool("secure", false, "Set Secure flag on session cookies")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
args := flag.Args()
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch args[0] {
|
||||||
|
case "version", "--version", "-v":
|
||||||
|
fmt.Printf("tm_server %s\n", version.BuildVersion)
|
||||||
|
return
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
case "import-json":
|
||||||
|
if err := runImportJSON(args[1:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
|
||||||
|
printHelp()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runServer(*listen, *dbPath, *secure); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(dbPath string) (*sqlite.DB, *config.Config, error) {
|
||||||
|
cfg, err := config.LoadOrCreate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
path := dbPath
|
||||||
|
if path == "" {
|
||||||
|
path = filepath.Join(cfg.DataDir, "task-master.db")
|
||||||
|
}
|
||||||
|
secret := []byte(os.Getenv("TASK_MASTER_SECRET"))
|
||||||
|
db, err := sqlite.Open(path, secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return db, cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrapAdmin(db *sqlite.DB) error {
|
||||||
|
n, err := db.CountUsers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user := strings.TrimSpace(os.Getenv("TM_ADMIN_USER"))
|
||||||
|
pass := os.Getenv("TM_ADMIN_PASSWORD")
|
||||||
|
if user == "" || pass == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "no users in database; set TM_ADMIN_USER and TM_ADMIN_PASSWORD to create the first admin")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := db.CreateUser(user, pass, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "created bootstrap admin %q (%s)\n", u.Username, u.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(listen, dbPath string, secure bool) error {
|
||||||
|
db, cfg, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := bootstrapAdmin(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srv := server.New(db)
|
||||||
|
srv.DataDir = cfg.DataDir
|
||||||
|
srv.Secure = secure
|
||||||
|
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
|
||||||
|
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
|
||||||
|
if err := srv.SeedDemo(server.SeedDemoOptions{
|
||||||
|
ClientDataDir: cfg.DataDir,
|
||||||
|
BaseURL: server.SeedBaseURL(listen),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("seed: %w", err)
|
||||||
|
}
|
||||||
|
projects, _ := srv.Projects.List()
|
||||||
|
fmt.Fprintf(os.Stderr, "demo seed ready (%d projects; login admin/admin, alice/alice)\n", len(projects))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "tm_server %s listening on %s\n", version.BuildVersion, listen)
|
||||||
|
return http.ListenAndServe(listen, srv.Handler())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runImportJSON(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("import-json", flag.ExitOnError)
|
||||||
|
dataDir := fs.String("data", "", "JSON DataDir to import (default from config)")
|
||||||
|
dbPath := fs.String("db", "", "SQLite database path")
|
||||||
|
owner := fs.String("owner", "", "username to own imported projects")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
db, cfg, err := openDB(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := bootstrapAdmin(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := *dataDir
|
||||||
|
if dir == "" {
|
||||||
|
dir = cfg.DataDir
|
||||||
|
}
|
||||||
|
var ownerID string
|
||||||
|
if *owner != "" {
|
||||||
|
u, err := db.GetUserByUsername(*owner)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("owner: %w", err)
|
||||||
|
}
|
||||||
|
ownerID = u.ID
|
||||||
|
} else {
|
||||||
|
users, err := db.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return fmt.Errorf("no users; create admin first (TM_ADMIN_USER/TM_ADMIN_PASSWORD)")
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.IsAdmin {
|
||||||
|
ownerID = u.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ownerID == "" {
|
||||||
|
ownerID = users[0].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := server.New(db)
|
||||||
|
fmt.Fprintf(os.Stderr, "importing from %s …\n", dir)
|
||||||
|
if err := srv.ImportJSONDataDir(dir, ownerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "import done")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printHelp() {
|
||||||
|
fmt.Print(`tm_server — task-master web server
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tm_server [flags]
|
||||||
|
tm_server import-json [-data DIR] [-db PATH] [-owner USER]
|
||||||
|
tm_server version
|
||||||
|
tm_server help
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
-listen ADDR HTTP listen address (default :8080)
|
||||||
|
-db PATH SQLite path (default <dataDir>/task-master.db)
|
||||||
|
-secure Set Secure cookie flag
|
||||||
|
|
||||||
|
Env:
|
||||||
|
TASK_MASTER_HOME / TASK_MASTER_DATA_DIR same as tm
|
||||||
|
TASK_MASTER_SECRET encrypts remote tokens
|
||||||
|
TM_ADMIN_USER / TM_ADMIN_PASSWORD bootstrap first admin
|
||||||
|
TM_SEED_DEMO=1 seed demo projects + wire TUI (Test, Website Relaunch)
|
||||||
|
TM_SEED_BASE_URL override base URL written into TUI meta.json
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ Hide
|
|||||||
Type "rm -rf /tmp/task-master-vhs && mkdir -p /tmp/task-master-vhs"
|
Type "rm -rf /tmp/task-master-vhs && mkdir -p /tmp/task-master-vhs"
|
||||||
Enter
|
Enter
|
||||||
Sleep 300ms
|
Sleep 300ms
|
||||||
Type "make build"
|
Type "test -x ./bin/tm || make build"
|
||||||
Enter
|
Enter
|
||||||
Sleep 5s
|
Sleep 5s
|
||||||
Show
|
Show
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const metaFileName = "meta.json"
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendNone = "none"
|
||||||
|
BackendYouTrack = "youtrack"
|
||||||
|
BackendTaskMaster = "taskmaster"
|
||||||
|
)
|
||||||
|
|
||||||
|
// YouTrack holds remote sync settings for a project.
|
||||||
|
type YouTrack struct {
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ProjectKey string `json:"projectKey"`
|
||||||
|
// Query overrides the default "project: <ProjectKey>" issue filter.
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether enough fields are set to sync.
|
||||||
|
func (y YouTrack) Configured() bool {
|
||||||
|
return strings.TrimSpace(y.BaseURL) != "" &&
|
||||||
|
strings.TrimSpace(y.Token) != "" &&
|
||||||
|
strings.TrimSpace(y.ProjectKey) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueQuery returns the YouTrack search query used for sync.
|
||||||
|
func (y YouTrack) IssueQuery() string {
|
||||||
|
if q := strings.TrimSpace(y.Query); q != "" {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
return "project: " + strings.TrimSpace(y.ProjectKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskMaster holds sync settings for a remote tm_server project.
|
||||||
|
type TaskMaster struct {
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether task-master sync can run.
|
||||||
|
func (t TaskMaster) Configured() bool {
|
||||||
|
return strings.TrimSpace(t.BaseURL) != "" &&
|
||||||
|
strings.TrimSpace(t.Token) != "" &&
|
||||||
|
strings.TrimSpace(t.ProjectID) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// McpAuth holds the hashed local MCP token for a project (plaintext never stored).
|
||||||
|
type McpAuth struct {
|
||||||
|
TokenHash string `json:"tokenHash"`
|
||||||
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta is per-project metadata stored beside tickets.json.
|
||||||
|
type Meta struct {
|
||||||
|
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
|
||||||
|
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||||
|
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
|
||||||
|
McpAuth *McpAuth `json:"mcpAuth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
|
||||||
|
func (m Meta) EffectiveBackend() string {
|
||||||
|
b := strings.TrimSpace(strings.ToLower(m.Backend))
|
||||||
|
switch b {
|
||||||
|
case BackendYouTrack, BackendTaskMaster, BackendNone:
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if m.YouTrack != nil && m.YouTrack.Configured() {
|
||||||
|
return BackendYouTrack
|
||||||
|
}
|
||||||
|
if m.TaskMaster != nil && m.TaskMaster.Configured() {
|
||||||
|
return BackendTaskMaster
|
||||||
|
}
|
||||||
|
if m.YouTrack != nil {
|
||||||
|
return BackendYouTrack
|
||||||
|
}
|
||||||
|
if m.TaskMaster != nil {
|
||||||
|
return BackendTaskMaster
|
||||||
|
}
|
||||||
|
return BackendNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncConfigured reports whether the active backend is fully set up.
|
||||||
|
func (m Meta) SyncConfigured() bool {
|
||||||
|
switch m.EffectiveBackend() {
|
||||||
|
case BackendYouTrack:
|
||||||
|
return m.YouTrack != nil && m.YouTrack.Configured()
|
||||||
|
case BackendTaskMaster:
|
||||||
|
return m.TaskMaster != nil && m.TaskMaster.Configured()
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DirStore) metaPath(id string) string {
|
||||||
|
return filepath.Join(s.projectsRoot(), id, metaFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMeta reads projects/<id>/meta.json. Missing file yields empty Meta.
|
||||||
|
func (s *DirStore) LoadMeta(id string) (Meta, error) {
|
||||||
|
if id == "" {
|
||||||
|
return Meta{}, fmt.Errorf("project id is required")
|
||||||
|
}
|
||||||
|
path := s.metaPath(id)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return Meta{}, nil
|
||||||
|
}
|
||||||
|
return Meta{}, fmt.Errorf("read meta %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return Meta{}, nil
|
||||||
|
}
|
||||||
|
var meta Meta
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if meta.Backend == "" && meta.YouTrack != nil {
|
||||||
|
meta.Backend = BackendYouTrack
|
||||||
|
}
|
||||||
|
if meta.Backend == "" && meta.TaskMaster != nil {
|
||||||
|
meta.Backend = BackendTaskMaster
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMeta writes projects/<id>/meta.json with mode 0600 (may contain tokens).
|
||||||
|
func (s *DirStore) SaveMeta(id string, meta Meta) error {
|
||||||
|
if id == "" {
|
||||||
|
return fmt.Errorf("project id is required")
|
||||||
|
}
|
||||||
|
if err := s.Ensure(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if meta.Backend == "" {
|
||||||
|
meta.Backend = meta.EffectiveBackend()
|
||||||
|
}
|
||||||
|
path := s.metaPath(id)
|
||||||
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal meta: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), "meta-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp meta: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer func() { _ = os.Remove(tmpName) }()
|
||||||
|
|
||||||
|
if err := tmp.Chmod(0o600); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("chmod temp meta: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("write temp meta: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temp meta: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return fmt.Errorf("rename meta: %w", err)
|
||||||
|
}
|
||||||
|
_ = os.Chmod(path, 0o600)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package project_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"task/internal/project"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetaSaveLoad(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := project.NewDirStore(dir)
|
||||||
|
if err := store.Ensure("inbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := project.Meta{
|
||||||
|
YouTrack: &project.YouTrack{
|
||||||
|
BaseURL: "https://yt.example.com",
|
||||||
|
Token: "perm:secret",
|
||||||
|
ProjectKey: "PROJ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := store.SaveMeta("inbox", meta); err != nil {
|
||||||
|
t.Fatalf("SaveMeta: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "projects", "inbox", "meta.json")
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
if st.Mode().Perm()&0o077 != 0 {
|
||||||
|
t.Fatalf("meta permissions too open: %o", st.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.LoadMeta("inbox")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMeta: %v", err)
|
||||||
|
}
|
||||||
|
if got.YouTrack == nil || got.YouTrack.Token != "perm:secret" || got.YouTrack.ProjectKey != "PROJ" {
|
||||||
|
t.Fatalf("got %+v", got.YouTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMetaMissing(t *testing.T) {
|
||||||
|
store := project.NewDirStore(t.TempDir())
|
||||||
|
got, err := store.LoadMeta("missing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMeta: %v", err)
|
||||||
|
}
|
||||||
|
if got.YouTrack != nil {
|
||||||
|
t.Fatalf("expected empty meta, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYouTrackConfiguredAndQuery(t *testing.T) {
|
||||||
|
y := project.YouTrack{BaseURL: "https://x", Token: "t", ProjectKey: "ABC"}
|
||||||
|
if !y.Configured() {
|
||||||
|
t.Fatal("expected configured")
|
||||||
|
}
|
||||||
|
if y.IssueQuery() != "project: ABC" {
|
||||||
|
t.Fatalf("query = %q", y.IssueQuery())
|
||||||
|
}
|
||||||
|
y.Query = "Assignee: me"
|
||||||
|
if y.IssueQuery() != "Assignee: me" {
|
||||||
|
t.Fatalf("query = %q", y.IssueQuery())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,6 @@ package project
|
|||||||
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type Store interface {
|
|||||||
List() ([]Project, error)
|
List() ([]Project, error)
|
||||||
Ensure(id string) error
|
Ensure(id string) error
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
|
LoadMeta(id string) (Meta, error)
|
||||||
|
SaveMeta(id string, meta Meta) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// DirStore discovers projects as subdirectories of dataDir/projects.
|
// DirStore discovers projects as subdirectories of dataDir/projects.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,8 @@ func (s *JSONStore) Update(t Ticket) error {
|
|||||||
for i := range tickets {
|
for i := range tickets {
|
||||||
if tickets[i].ID == t.ID {
|
if tickets[i].ID == t.ID {
|
||||||
t.CreatedAt = tickets[i].CreatedAt
|
t.CreatedAt = tickets[i].CreatedAt
|
||||||
|
// Always bump UpdatedAt on local edits so NeedsPush sees dirty tickets.
|
||||||
|
// Use MarkSynced after a successful remote sync to clear the dirty flag.
|
||||||
t.UpdatedAt = time.Now().UTC()
|
t.UpdatedAt = time.Now().UTC()
|
||||||
tickets[i] = t
|
tickets[i] = t
|
||||||
found = true
|
found = true
|
||||||
@@ -146,6 +148,42 @@ func (s *JSONStore) Update(t Ticket) error {
|
|||||||
return s.writeUnlocked(t.ProjectID, tickets)
|
return s.writeUnlocked(t.ProjectID, tickets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkSynced sets SyncedAt = UpdatedAt without changing UpdatedAt.
|
||||||
|
func (s *JSONStore) MarkSynced(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
return fmt.Errorf("ticket id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("ticket %s: not found", id)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("list projects: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range projects {
|
||||||
|
if !p.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tickets, err := s.readUnlocked(p.Name())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range tickets {
|
||||||
|
if tickets[i].ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tickets[i].SyncedAt = tickets[i].UpdatedAt
|
||||||
|
return s.writeUnlocked(p.Name(), tickets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ticket %s: not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
// Move relocates a ticket to another project directory.
|
// Move relocates a ticket to another project directory.
|
||||||
func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) {
|
func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"task/internal/ticket"
|
"task/internal/ticket"
|
||||||
)
|
)
|
||||||
@@ -65,6 +66,43 @@ func TestJSONStoreCreateListUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateAfterMarkSyncedIsDirty(t *testing.T) {
|
||||||
|
store := ticket.NewJSONStore(t.TempDir())
|
||||||
|
created, err := store.Create(ticket.Ticket{
|
||||||
|
Title: "Synced", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||||
|
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.MarkSynced(created.ID); err != nil {
|
||||||
|
t.Fatalf("MarkSynced: %v", err)
|
||||||
|
}
|
||||||
|
synced, err := store.Get(created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if synced.UpdatedAt.After(synced.SyncedAt) {
|
||||||
|
t.Fatalf("freshly marked synced should not be dirty: updated=%v syncedAt=%v",
|
||||||
|
synced.UpdatedAt, synced.SyncedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate UI edit: copy ticket (SyncedAt == UpdatedAt) and change title.
|
||||||
|
edited := synced
|
||||||
|
edited.Title = "Edited locally"
|
||||||
|
time.Sleep(time.Millisecond) // ensure clock can move
|
||||||
|
if err := store.Update(edited); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := store.Get(created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.UpdatedAt.After(got.SyncedAt) {
|
||||||
|
t.Fatalf("edit must bump UpdatedAt past SyncedAt: %v vs %v", got.UpdatedAt, got.SyncedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
projectDir := filepath.Join(dir, "projects", "inbox")
|
projectDir := filepath.Join(dir, "projects", "inbox")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ type Store interface {
|
|||||||
Get(id string) (Ticket, error)
|
Get(id string) (Ticket, error)
|
||||||
Create(t Ticket) (Ticket, error)
|
Create(t Ticket) (Ticket, error)
|
||||||
Update(t Ticket) error
|
Update(t Ticket) error
|
||||||
|
// MarkSynced sets SyncedAt to the ticket's current UpdatedAt without
|
||||||
|
// bumping UpdatedAt (so NeedsPush becomes false after a remote sync).
|
||||||
|
MarkSynced(id string) error
|
||||||
Move(id, toProjectID string) (Ticket, error)
|
Move(id, toProjectID string) (Ticket, error)
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ func (s Status) Index() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SourceYouTrack marks tickets imported from YouTrack.
|
||||||
|
SourceYouTrack = "youtrack"
|
||||||
|
// SourceTaskMaster marks tickets synced from a task-master server.
|
||||||
|
SourceTaskMaster = "taskmaster"
|
||||||
|
)
|
||||||
|
|
||||||
// Ticket is the domain model for a work item.
|
// Ticket is the domain model for a work item.
|
||||||
// Title (headline) and Description are Markdown.
|
// Title (headline) and Description are Markdown.
|
||||||
type Ticket struct {
|
type Ticket struct {
|
||||||
@@ -60,4 +67,23 @@ type Ticket struct {
|
|||||||
ProjectID string `json:"projectId"`
|
ProjectID string `json:"projectId"`
|
||||||
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.
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
|
ExternalURL string `json:"externalUrl,omitempty"`
|
||||||
|
SyncedAt time.Time `json:"syncedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayID returns the human-readable ticket label for UI.
|
||||||
|
// Prefers the server key; falls back to ExternalID (e.g. YouTrack idReadable).
|
||||||
|
func (t Ticket) DisplayID() string {
|
||||||
|
if t.Key != "" {
|
||||||
|
return t.Key
|
||||||
|
}
|
||||||
|
return t.ExternalID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const (
|
|||||||
SourceManual = "manual"
|
SourceManual = "manual"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SourceYouTrackTime marks time entries imported from YouTrack work items.
|
||||||
|
SourceYouTrackTime = "youtrack"
|
||||||
|
)
|
||||||
|
|
||||||
// TimeEntry is a recorded block of work on a ticket.
|
// TimeEntry is a recorded block of work on a ticket.
|
||||||
type TimeEntry struct {
|
type TimeEntry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -21,6 +26,8 @@ type TimeEntry struct {
|
|||||||
Duration time.Duration `json:"duration"`
|
Duration time.Duration `json:"duration"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
|
// ExternalID is the YouTrack work-item id when synced.
|
||||||
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON encodes Duration as nanoseconds for stable JSON.
|
// MarshalJSON encodes Duration as nanoseconds for stable JSON.
|
||||||
@@ -34,6 +41,7 @@ func (e TimeEntry) MarshalJSON() ([]byte, error) {
|
|||||||
Duration int64 `json:"duration"`
|
Duration int64 `json:"duration"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
}
|
}
|
||||||
return json.Marshal(alias{
|
return json.Marshal(alias{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
@@ -44,6 +52,7 @@ func (e TimeEntry) MarshalJSON() ([]byte, error) {
|
|||||||
Duration: int64(e.Duration),
|
Duration: int64(e.Duration),
|
||||||
Source: e.Source,
|
Source: e.Source,
|
||||||
Note: e.Note,
|
Note: e.Note,
|
||||||
|
ExternalID: e.ExternalID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +67,7 @@ func (e *TimeEntry) UnmarshalJSON(data []byte) error {
|
|||||||
Duration int64 `json:"duration"`
|
Duration int64 `json:"duration"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
}
|
}
|
||||||
var a alias
|
var a alias
|
||||||
if err := json.Unmarshal(data, &a); err != nil {
|
if err := json.Unmarshal(data, &a); err != nil {
|
||||||
@@ -71,6 +81,7 @@ func (e *TimeEntry) UnmarshalJSON(data []byte) error {
|
|||||||
e.Duration = time.Duration(a.Duration)
|
e.Duration = time.Duration(a.Duration)
|
||||||
e.Source = a.Source
|
e.Source = a.Source
|
||||||
e.Note = a.Note
|
e.Note = a.Note
|
||||||
|
e.ExternalID = a.ExternalID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+378
-28
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,8 +19,23 @@ import (
|
|||||||
"task/internal/ticket"
|
"task/internal/ticket"
|
||||||
"task/internal/ui/views"
|
"task/internal/ui/views"
|
||||||
"task/internal/version"
|
"task/internal/version"
|
||||||
|
"task/internal/youtrack"
|
||||||
|
"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
|
||||||
|
|
||||||
@@ -31,6 +47,15 @@ type editorFinishedMsg struct {
|
|||||||
create bool // true: create ticket from edited file
|
create bool // true: create ticket from edited file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncFinishedMsg is sent after a manual YouTrack pull or push completes.
|
||||||
|
type syncFinishedMsg struct {
|
||||||
|
projectID string
|
||||||
|
action views.SyncAction
|
||||||
|
pull ytsync.Result
|
||||||
|
push ytsync.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// Mode is the active UI mode.
|
// Mode is the active UI mode.
|
||||||
type Mode int
|
type Mode int
|
||||||
|
|
||||||
@@ -48,6 +73,8 @@ const (
|
|||||||
ModeTimeEdit
|
ModeTimeEdit
|
||||||
ModeReport
|
ModeReport
|
||||||
ModeReportGroup
|
ModeReportGroup
|
||||||
|
ModeEditProject
|
||||||
|
ModeSync
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model is the root Bubble Tea model.
|
// Model is the root Bubble Tea model.
|
||||||
@@ -62,6 +89,8 @@ type Model struct {
|
|||||||
tickets views.TicketList
|
tickets views.TicketList
|
||||||
projectList list.Model
|
projectList list.Model
|
||||||
statusMenu views.StatusMenu
|
statusMenu views.StatusMenu
|
||||||
|
syncMenu views.SyncMenu
|
||||||
|
syncProjectID string // project targeted by ModeSync
|
||||||
boardMenu views.BoardMenu
|
boardMenu views.BoardMenu
|
||||||
boardFilter ticket.BoardFilter
|
boardFilter ticket.BoardFilter
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
@@ -71,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
|
||||||
@@ -85,10 +115,14 @@ type Model struct {
|
|||||||
creatingProject bool
|
creatingProject bool
|
||||||
pendingDeleteTitle string
|
pendingDeleteTitle string
|
||||||
pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket
|
pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket
|
||||||
|
projectEdit views.ProjectEditForm
|
||||||
|
projectEditMeta project.Meta // snapshot when opening the form
|
||||||
|
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()
|
||||||
@@ -100,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,
|
||||||
@@ -146,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
|
||||||
@@ -161,16 +205,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case editorFinishedMsg:
|
case editorFinishedMsg:
|
||||||
return m.handleEditorFinished(msg)
|
return m.handleEditorFinished(msg)
|
||||||
|
|
||||||
|
case syncFinishedMsg:
|
||||||
|
m.syncing = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.setError(msg.err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := m.reloadTickets(); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch msg.action {
|
||||||
|
case views.SyncActionPush:
|
||||||
|
m.setStatus(fmt.Sprintf(
|
||||||
|
"pushed %s: +%d ~%d · time %d",
|
||||||
|
msg.projectID, msg.push.Created, msg.push.Updated, msg.push.TimePushed,
|
||||||
|
))
|
||||||
|
default:
|
||||||
|
m.setStatus(fmt.Sprintf(
|
||||||
|
"pulled %s: +%d ~%d · time %d",
|
||||||
|
msg.projectID, msg.pull.Created, msg.pull.Updated, msg.pull.TimePulled,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
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:
|
||||||
|
return m.updateEditProject(msg)
|
||||||
case ModeProjects:
|
case ModeProjects:
|
||||||
return m.updateProjects(msg)
|
return m.updateProjects(msg)
|
||||||
case ModeMoveTicket:
|
case ModeMoveTicket:
|
||||||
return m.updateMoveTicket(msg)
|
return m.updateMoveTicket(msg)
|
||||||
case ModeStatus:
|
case ModeStatus:
|
||||||
return m.updateStatus(msg)
|
return m.updateStatus(msg)
|
||||||
|
case ModeSync:
|
||||||
|
return m.updateSync(msg)
|
||||||
case ModeBoard:
|
case ModeBoard:
|
||||||
return m.updateBoard(msg)
|
return m.updateBoard(msg)
|
||||||
case ModeConfirmDelete:
|
case ModeConfirmDelete:
|
||||||
@@ -190,8 +264,12 @@ 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:
|
||||||
|
cmd = m.projectEdit.Update(msg)
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
@@ -373,6 +451,11 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = ModeReport
|
m.mode = ModeReport
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Sync):
|
||||||
|
m.clearStatus()
|
||||||
|
m.openSyncMenu(m.cfg.ActiveProject)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.TimeEntries):
|
case key.Matches(msg, m.keys.TimeEntries):
|
||||||
m.clearStatus()
|
m.clearStatus()
|
||||||
if err := m.openTimeEntries(); err != nil {
|
if err := m.openTimeEntries(); err != nil {
|
||||||
@@ -530,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
|
||||||
}
|
}
|
||||||
@@ -553,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)
|
||||||
@@ -594,7 +665,7 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.setStatus(fmt.Sprintf("created project %s", id))
|
m.setStatus(fmt.Sprintf("created project %s", id))
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if err := m.createTicket(value, ""); err != nil {
|
if _, err := m.createTicket(value, ""); err != nil {
|
||||||
m.setError(err)
|
m.setError(err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -653,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):
|
||||||
@@ -749,6 +818,157 @@ func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Escape):
|
||||||
|
m.mode = ModeProjects
|
||||||
|
m.clearStatus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Tab):
|
||||||
|
m.projectEdit.NextField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ShiftTab):
|
||||||
|
m.projectEdit.PrevField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case msg.Type == tea.KeyDown:
|
||||||
|
m.projectEdit.NextField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case msg.Type == tea.KeyUp:
|
||||||
|
m.projectEdit.PrevField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case msg.Type == tea.KeyLeft:
|
||||||
|
m.projectEdit.CycleBackend(-1)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case msg.Type == tea.KeyRight:
|
||||||
|
m.projectEdit.CycleBackend(1)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
meta := m.projectEdit.Meta(m.projectEditMeta)
|
||||||
|
id := m.projectEdit.ProjectID
|
||||||
|
if err := m.projects.SaveMeta(id, meta); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := m.openProjects(); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = ModeProjects
|
||||||
|
if meta.SyncConfigured() {
|
||||||
|
m.setStatus(fmt.Sprintf("saved %s sync for %s", meta.EffectiveBackend(), id))
|
||||||
|
} else {
|
||||||
|
m.setStatus(fmt.Sprintf("cleared sync settings for %s", id))
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := m.projectEdit.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) openProjectEdit(projectID string) error {
|
||||||
|
meta, err := m.projects.LoadMeta(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.projectEditMeta = meta
|
||||||
|
m.projectEdit = views.NewProjectEditForm(projectID, meta, m.width)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) openSyncMenu(projectID string) {
|
||||||
|
m.syncProjectID = projectID
|
||||||
|
label := "Sync"
|
||||||
|
if meta, err := m.projects.LoadMeta(projectID); err == nil {
|
||||||
|
switch meta.EffectiveBackend() {
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
label = "task-master"
|
||||||
|
case project.BackendYouTrack:
|
||||||
|
label = "YouTrack"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
|
||||||
|
Box: m.styles.StatusMenuBox,
|
||||||
|
Title: m.styles.StatusMenuTitle,
|
||||||
|
Selected: m.styles.StatusMenuSelected,
|
||||||
|
Normal: m.styles.StatusMenuNormal,
|
||||||
|
}, label)
|
||||||
|
m.mode = ModeSync
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateSync(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Escape):
|
||||||
|
m.mode = ModeList
|
||||||
|
m.clearStatus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
action := m.syncMenu.Selected()
|
||||||
|
m.mode = ModeList
|
||||||
|
return m.startSyncAction(m.syncProjectID, action)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Down):
|
||||||
|
m.syncMenu.MoveDown()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Up):
|
||||||
|
m.syncMenu.MoveUp()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
||||||
|
if m.syncMenu.JumpToShortcut(msg.Runes[0]) {
|
||||||
|
action := m.syncMenu.Selected()
|
||||||
|
m.mode = ModeList
|
||||||
|
return m.startSyncAction(m.syncProjectID, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.Model, tea.Cmd) {
|
||||||
|
if m.syncing {
|
||||||
|
m.setStatus("sync already running…")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.remote == nil {
|
||||||
|
m.setError(fmt.Errorf("daemon client not available"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.clearStatus()
|
||||||
|
m.syncing = true
|
||||||
|
switch action {
|
||||||
|
case views.SyncActionPush:
|
||||||
|
m.setStatus(fmt.Sprintf("pushing %s…", projectID))
|
||||||
|
default:
|
||||||
|
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
|
||||||
|
}
|
||||||
|
remote := m.remote
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
var pull, push ytsync.Result
|
||||||
|
var err error
|
||||||
|
switch action {
|
||||||
|
case views.SyncActionPush:
|
||||||
|
push, err = remote.SyncPush(projectID)
|
||||||
|
default:
|
||||||
|
pull, err = remote.SyncPull(projectID)
|
||||||
|
}
|
||||||
|
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) updateMoveTicket(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) updateMoveTicket(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Escape):
|
case key.Matches(msg, m.keys.Escape):
|
||||||
@@ -796,6 +1016,65 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.input.Focus()
|
m.input.Focus()
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Edit):
|
||||||
|
m.clearStatus()
|
||||||
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
m.setError(fmt.Errorf("no project selected"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := m.openProjectEdit(item.Project.ID); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = ModeEditProject
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Sync):
|
||||||
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
m.setError(fmt.Errorf("no project selected"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.clearStatus()
|
||||||
|
m.openSyncMenu(item.Project.ID)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.MCPToken):
|
||||||
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
m.setError(fmt.Errorf("no project selected"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.remote == nil {
|
||||||
|
m.setError(fmt.Errorf("daemon client not available"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
token, _, err := m.remote.CreateMCPToken(item.Project.ID)
|
||||||
|
if err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.setStatus(fmt.Sprintf("MCP token for %s (save as .tm_token): project=%s token=%s", item.Project.ID, item.Project.ID, token))
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.MCPTokenRevoke):
|
||||||
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
m.setError(fmt.Errorf("no project selected"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.remote == nil {
|
||||||
|
m.setError(fmt.Errorf("daemon client not available"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := m.remote.RevokeMCPToken(item.Project.ID); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.setStatus(fmt.Sprintf("revoked MCP token for %s", item.Project.ID))
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.DeleteForce):
|
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)
|
||||||
@@ -904,7 +1183,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if msg.create {
|
if msg.create {
|
||||||
if err := m.createTicket(title, description); err != nil {
|
if _, err := m.createTicket(title, description); err != nil {
|
||||||
m.setError(err)
|
m.setError(err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -941,7 +1220,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd)
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) createTicket(title, description string) error {
|
func (m *Model) createTicket(title, description string) (ticket.Ticket, error) {
|
||||||
cmd := &command.CreateTicket{
|
cmd := &command.CreateTicket{
|
||||||
Store: m.store,
|
Store: m.store,
|
||||||
Ticket: ticket.Ticket{
|
Ticket: ticket.Ticket{
|
||||||
@@ -952,9 +1231,12 @@ func (m *Model) createTicket(title, description string) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := m.exec.Do(cmd); err != nil {
|
if err := m.exec.Do(cmd); err != nil {
|
||||||
return err
|
return ticket.Ticket{}, err
|
||||||
}
|
}
|
||||||
return m.reloadTickets()
|
if err := m.reloadTickets(); err != nil {
|
||||||
|
return ticket.Ticket{}, err
|
||||||
|
}
|
||||||
|
return cmd.Ticket, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) deleteSelected() error {
|
func (m *Model) deleteSelected() error {
|
||||||
@@ -1232,7 +1514,15 @@ func (m *Model) openProjects() error {
|
|||||||
if bodyH < 1 {
|
if bodyH < 1 {
|
||||||
bodyH = 1
|
bodyH = 1
|
||||||
}
|
}
|
||||||
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
|
yt := make(map[string]bool, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
meta, err := m.projects.LoadMeta(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
yt[p.ID] = meta.SyncConfigured()
|
||||||
|
}
|
||||||
|
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1424,6 +1714,13 @@ func (m *Model) deleteSelectedTimeEntry() error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("no entry selected")
|
return fmt.Errorf("no entry selected")
|
||||||
}
|
}
|
||||||
|
// If synced to YouTrack, remove the remote work item first so the next
|
||||||
|
// pull does not resurrect it.
|
||||||
|
if e.ExternalID != "" {
|
||||||
|
if err := m.deleteRemoteWorkItem(e); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := m.exec.Do(&command.RemoveTimeEntry{Store: m.timeStore, Snapshot: e}); err != nil {
|
if err := m.exec.Do(&command.RemoveTimeEntry{Store: m.timeStore, Snapshot: e}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1433,18 +1730,71 @@ func (m *Model) deleteSelectedTimeEntry() error {
|
|||||||
return m.openTimeEntriesFor(m.timeEntriesTicket)
|
return m.openTimeEntriesFor(m.timeEntriesTicket)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) saveEditedTime(raw string) error {
|
func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error {
|
||||||
|
tk, err := m.store.Get(e.TicketID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve ticket for work item: %w", err)
|
||||||
|
}
|
||||||
|
if tk.ExternalID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
meta, err := m.projects.LoadMeta(tk.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if meta.YouTrack == nil || !meta.YouTrack.Configured() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := &youtrack.Client{BaseURL: meta.YouTrack.BaseURL, Token: meta.YouTrack.Token}
|
||||||
|
if err := client.DeleteWorkItem(context.Background(), tk.ExternalID, e.ExternalID); err != nil {
|
||||||
|
return fmt.Errorf("delete YouTrack work item: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
+66
-31
@@ -33,10 +33,18 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"a-z", "jump"},
|
{"a-z", "jump"},
|
||||||
{"enter", "select"},
|
{"enter", "select"},
|
||||||
{"n", "new project"},
|
{"n", "new project"},
|
||||||
|
{"e", "edit"},
|
||||||
|
{"Y", "Sync"},
|
||||||
{"x", "delete"},
|
{"x", "delete"},
|
||||||
{"X", "force delete"},
|
{"X", "force delete"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
case ModeEditProject:
|
||||||
|
return []helpEntry{
|
||||||
|
{"tab/↑↓", "fields"},
|
||||||
|
{"enter", "save"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
case ModeMoveTicket:
|
case ModeMoveTicket:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
@@ -51,6 +59,13 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"enter", "apply"},
|
{"enter", "apply"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
case ModeSync:
|
||||||
|
return []helpEntry{
|
||||||
|
{"j/k", "select"},
|
||||||
|
{"p/u", "jump"},
|
||||||
|
{"enter", "run"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
case ModeBoard:
|
case ModeBoard:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
@@ -72,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"},
|
||||||
}
|
}
|
||||||
@@ -106,6 +122,7 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"t/T", "timer/log"},
|
{"t/T", "timer/log"},
|
||||||
{"m", "entries"},
|
{"m", "entries"},
|
||||||
{"r", "report"},
|
{"r", "report"},
|
||||||
|
{"Y", "Sync"},
|
||||||
{"u/U", "undo/redo"},
|
{"u/U", "undo/redo"},
|
||||||
{"/", "search"},
|
{"/", "search"},
|
||||||
{"p", "project"},
|
{"p", "project"},
|
||||||
@@ -135,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 {
|
|
||||||
lines = append(lines, cur)
|
|
||||||
cur = ""
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if lipgloss.Width(cur)+sepW+pw <= width {
|
|
||||||
cur = cur + sep + p
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, cur)
|
|
||||||
cur = p
|
|
||||||
if pw > width {
|
|
||||||
lines = append(lines, cur)
|
|
||||||
cur = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cur != "" {
|
if cellW < 1 {
|
||||||
lines = append(lines, cur)
|
cellW = 1
|
||||||
}
|
}
|
||||||
if len(lines) == 0 {
|
|
||||||
return []string{""}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, strings.Join(cells, sep))
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@@ -186,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.
|
||||||
|
|||||||
+26
-1
@@ -24,11 +24,16 @@ type KeyMap struct {
|
|||||||
TimeManual key.Binding // T — manual duration
|
TimeManual key.Binding // T — manual duration
|
||||||
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
|
||||||
|
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
|
||||||
Enter key.Binding
|
Enter key.Binding
|
||||||
Escape key.Binding
|
Escape key.Binding
|
||||||
|
Tab key.Binding // tab — next form field
|
||||||
|
ShiftTab key.Binding // shift+tab — previous form field
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultKeyMap returns the default key bindings.
|
// DefaultKeyMap returns the default key bindings.
|
||||||
@@ -114,6 +119,18 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("r"),
|
key.WithKeys("r"),
|
||||||
key.WithHelp("r", "report"),
|
key.WithHelp("r", "report"),
|
||||||
),
|
),
|
||||||
|
Sync: key.NewBinding(
|
||||||
|
key.WithKeys("Y"),
|
||||||
|
key.WithHelp("Y", "YouTrack pull/push"),
|
||||||
|
),
|
||||||
|
MCPToken: key.NewBinding(
|
||||||
|
key.WithKeys("g"),
|
||||||
|
key.WithHelp("g", "MCP token"),
|
||||||
|
),
|
||||||
|
MCPTokenRevoke: key.NewBinding(
|
||||||
|
key.WithKeys("G"),
|
||||||
|
key.WithHelp("G", "revoke MCP token"),
|
||||||
|
),
|
||||||
Undo: key.NewBinding(
|
Undo: key.NewBinding(
|
||||||
key.WithKeys("u", "ctrl+z"),
|
key.WithKeys("u", "ctrl+z"),
|
||||||
key.WithHelp("u", "undo"),
|
key.WithHelp("u", "undo"),
|
||||||
@@ -134,12 +151,20 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("esc"),
|
key.WithKeys("esc"),
|
||||||
key.WithHelp("esc", "cancel"),
|
key.WithHelp("esc", "cancel"),
|
||||||
),
|
),
|
||||||
|
Tab: key.NewBinding(
|
||||||
|
key.WithKeys("tab"),
|
||||||
|
key.WithHelp("tab", "next field"),
|
||||||
|
),
|
||||||
|
ShiftTab: key.NewBinding(
|
||||||
|
key.WithKeys("shift+tab"),
|
||||||
|
key.WithHelp("shift+tab", "prev field"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.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)
|
||||||
@@ -80,10 +78,17 @@ func (m Model) renderBody(height int) string {
|
|||||||
m.projectList.SetSize(m.width, height)
|
m.projectList.SetSize(m.width, height)
|
||||||
return m.projectList.View()
|
return m.projectList.View()
|
||||||
|
|
||||||
|
case ModeEditProject:
|
||||||
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.projectEdit.View())
|
||||||
|
|
||||||
case ModeStatus:
|
case ModeStatus:
|
||||||
menu := m.statusMenu.View()
|
menu := m.statusMenu.View()
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||||
|
|
||||||
|
case ModeSync:
|
||||||
|
menu := m.syncMenu.View()
|
||||||
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||||
|
|
||||||
case ModeBoard:
|
case ModeBoard:
|
||||||
menu := m.boardMenu.View()
|
menu := m.boardMenu.View()
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"task/internal/project"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectEdit field indices (backend picker is separate).
|
||||||
|
const (
|
||||||
|
FieldRemoteURL = iota
|
||||||
|
FieldToken
|
||||||
|
FieldProjectKey
|
||||||
|
ProjectEditFieldCount
|
||||||
|
)
|
||||||
|
|
||||||
|
var backendOptions = []string{
|
||||||
|
project.BackendNone,
|
||||||
|
project.BackendYouTrack,
|
||||||
|
project.BackendTaskMaster,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectEditStyles styles the project edit form.
|
||||||
|
type ProjectEditStyles struct {
|
||||||
|
Label lipgloss.Style
|
||||||
|
Focused lipgloss.Style
|
||||||
|
Blurred lipgloss.Style
|
||||||
|
Hint lipgloss.Style
|
||||||
|
Title lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultProjectEditStyles returns sensible defaults.
|
||||||
|
func DefaultProjectEditStyles() ProjectEditStyles {
|
||||||
|
return ProjectEditStyles{
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectEditForm is a multi-field editor for sync backend settings.
|
||||||
|
type ProjectEditForm struct {
|
||||||
|
ProjectID string
|
||||||
|
Backend string
|
||||||
|
BackendIdx int
|
||||||
|
FocusBackend bool // true when selecting backend
|
||||||
|
Inputs []textinput.Model
|
||||||
|
Focus int
|
||||||
|
Styles ProjectEditStyles
|
||||||
|
Width int
|
||||||
|
hadToken bool
|
||||||
|
tokenChanged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProjectEditForm builds a form prefilled from meta.
|
||||||
|
func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectEditForm {
|
||||||
|
styles := DefaultProjectEditStyles()
|
||||||
|
inputs := make([]textinput.Model, ProjectEditFieldCount)
|
||||||
|
|
||||||
|
mk := func(placeholder string, charLimit int, password bool) textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = placeholder
|
||||||
|
ti.CharLimit = charLimit
|
||||||
|
ti.Width = max(20, width-4)
|
||||||
|
if password {
|
||||||
|
ti.EchoMode = textinput.EchoPassword
|
||||||
|
ti.EchoCharacter = '•'
|
||||||
|
}
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
backend := meta.EffectiveBackend()
|
||||||
|
if backend == "" {
|
||||||
|
backend = project.BackendNone
|
||||||
|
}
|
||||||
|
bIdx := 0
|
||||||
|
for i, b := range backendOptions {
|
||||||
|
if b == backend {
|
||||||
|
bIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hadToken := false
|
||||||
|
switch backend {
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
|
||||||
|
inputs[FieldToken] = mk("tm_… project token", 512, true)
|
||||||
|
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
|
||||||
|
if meta.TaskMaster != nil {
|
||||||
|
tm := meta.TaskMaster
|
||||||
|
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
|
||||||
|
inputs[FieldProjectKey].SetValue(tm.ProjectID)
|
||||||
|
if tm.Token != "" {
|
||||||
|
hadToken = true
|
||||||
|
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
||||||
|
inputs[FieldToken] = mk("permanent token", 512, true)
|
||||||
|
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
||||||
|
if meta.YouTrack != nil {
|
||||||
|
yt := meta.YouTrack
|
||||||
|
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
||||||
|
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
||||||
|
if yt.Token != "" {
|
||||||
|
hadToken = true
|
||||||
|
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f := ProjectEditForm{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Backend: backend,
|
||||||
|
BackendIdx: bIdx,
|
||||||
|
FocusBackend: true,
|
||||||
|
Inputs: inputs,
|
||||||
|
Focus: FieldRemoteURL,
|
||||||
|
Styles: styles,
|
||||||
|
Width: width,
|
||||||
|
hadToken: hadToken,
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *ProjectEditForm) applyBackendPlaceholders() {
|
||||||
|
f.Backend = backendOptions[f.BackendIdx]
|
||||||
|
switch f.Backend {
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
|
||||||
|
f.Inputs[FieldToken].Placeholder = "tm_… project token"
|
||||||
|
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
|
||||||
|
case project.BackendYouTrack:
|
||||||
|
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
|
||||||
|
f.Inputs[FieldToken].Placeholder = "permanent token"
|
||||||
|
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
|
||||||
|
default:
|
||||||
|
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
|
||||||
|
f.Inputs[FieldToken].Placeholder = "(unused)"
|
||||||
|
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *ProjectEditForm) focusIndex(i int) {
|
||||||
|
f.FocusBackend = false
|
||||||
|
f.Focus = i
|
||||||
|
for idx := range f.Inputs {
|
||||||
|
if idx == i {
|
||||||
|
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 *ProjectEditForm) NextField() {
|
||||||
|
if f.FocusBackend {
|
||||||
|
if f.Backend == project.BackendNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.focusIndex(FieldRemoteURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrevField moves focus to the previous field.
|
||||||
|
func (f *ProjectEditForm) PrevField() {
|
||||||
|
if f.FocusBackend {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.Focus == FieldRemoteURL {
|
||||||
|
f.blurInputs()
|
||||||
|
f.FocusBackend = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *ProjectEditForm) blurInputs() {
|
||||||
|
for idx := range f.Inputs {
|
||||||
|
f.Inputs[idx].Blur()
|
||||||
|
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
||||||
|
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CycleBackend changes the selected backend when focusing the picker.
|
||||||
|
func (f *ProjectEditForm) CycleBackend(delta int) {
|
||||||
|
if !f.FocusBackend {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := len(backendOptions)
|
||||||
|
f.BackendIdx = (f.BackendIdx + delta + n) % n
|
||||||
|
f.applyBackendPlaceholders()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update forwards a message to the focused text input.
|
||||||
|
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
if f.FocusBackend || f.Backend == project.BackendNone {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
||||||
|
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
|
||||||
|
f.tokenChanged = true
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta builds the Meta to persist. Empty token keeps the previous token.
|
||||||
|
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
|
||||||
|
backend := backendOptions[f.BackendIdx]
|
||||||
|
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
|
||||||
|
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
|
||||||
|
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
|
||||||
|
|
||||||
|
switch backend {
|
||||||
|
case project.BackendNone:
|
||||||
|
return project.Meta{Backend: project.BackendNone}
|
||||||
|
case project.BackendTaskMaster:
|
||||||
|
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
|
||||||
|
if token == "" && previous.TaskMaster != nil {
|
||||||
|
tm.Token = previous.TaskMaster.Token
|
||||||
|
}
|
||||||
|
if baseURL == "" && token == "" && key == "" {
|
||||||
|
return project.Meta{Backend: project.BackendNone}
|
||||||
|
}
|
||||||
|
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
|
||||||
|
default:
|
||||||
|
yt := &project.YouTrack{BaseURL: baseURL, ProjectKey: key, Token: token}
|
||||||
|
if token == "" && previous.YouTrack != nil {
|
||||||
|
yt.Token = previous.YouTrack.Token
|
||||||
|
}
|
||||||
|
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
||||||
|
yt.Query = previous.YouTrack.Query
|
||||||
|
}
|
||||||
|
if baseURL == "" && token == "" && key == "" {
|
||||||
|
return project.Meta{Backend: project.BackendNone}
|
||||||
|
}
|
||||||
|
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ensures partial configs are rejected.
|
||||||
|
func (f ProjectEditForm) Validate(previous project.Meta) error {
|
||||||
|
meta := f.Meta(previous)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the form.
|
||||||
|
func (f ProjectEditForm) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(f.Styles.Hint.Render("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
backendLine := "Backend: "
|
||||||
|
for i, opt := range backendOptions {
|
||||||
|
label := opt
|
||||||
|
if i == f.BackendIdx {
|
||||||
|
if f.FocusBackend {
|
||||||
|
label = f.Styles.Focused.Render("[" + opt + "]")
|
||||||
|
} else {
|
||||||
|
label = f.Styles.Focused.Render(opt)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
label = f.Styles.Blurred.Render(opt)
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
backendLine += " "
|
||||||
|
}
|
||||||
|
backendLine += label
|
||||||
|
}
|
||||||
|
b.WriteString(backendLine)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if f.Backend == project.BackendNone {
|
||||||
|
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
|
||||||
|
b.WriteString("\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := []string{"Remote URL", "Token", "Project key"}
|
||||||
|
if f.Backend == project.BackendTaskMaster {
|
||||||
|
labels = []string{"Server URL", "Project token", "Remote project ID"}
|
||||||
|
}
|
||||||
|
for i, label := range labels {
|
||||||
|
b.WriteString(f.Styles.Label.Render(label + ":"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(f.Inputs[i].View())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
if f.hadToken && !f.tokenChanged {
|
||||||
|
b.WriteString(f.Styles.Hint.Render("Token is set; leave blank to keep."))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package views_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"task/internal/project"
|
||||||
|
"task/internal/ui/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectEditMetaKeepsToken(t *testing.T) {
|
||||||
|
prev := project.Meta{
|
||||||
|
Backend: project.BackendYouTrack,
|
||||||
|
YouTrack: &project.YouTrack{
|
||||||
|
BaseURL: "https://yt.example",
|
||||||
|
Token: "secret",
|
||||||
|
ProjectKey: "ABC",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := views.NewProjectEditForm("inbox", prev, 80)
|
||||||
|
// Leave token blank → keep previous.
|
||||||
|
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
||||||
|
f.Inputs[views.FieldProjectKey].SetValue("ABC")
|
||||||
|
f.Inputs[views.FieldToken].SetValue("")
|
||||||
|
|
||||||
|
meta := f.Meta(prev)
|
||||||
|
if meta.YouTrack == nil || meta.YouTrack.Token != "secret" {
|
||||||
|
t.Fatalf("expected kept token, got %+v", meta.YouTrack)
|
||||||
|
}
|
||||||
|
if err := f.Validate(prev); err != nil {
|
||||||
|
t.Fatalf("Validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectEditValidateIncomplete(t *testing.T) {
|
||||||
|
f := views.NewProjectEditForm("inbox", project.Meta{Backend: project.BackendYouTrack}, 80)
|
||||||
|
f.BackendIdx = 1 // youtrack
|
||||||
|
f.Backend = project.BackendYouTrack
|
||||||
|
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
||||||
|
// missing token + key
|
||||||
|
if err := f.Validate(project.Meta{}); err == nil {
|
||||||
|
t.Fatal("expected incomplete error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectEditTaskMaster(t *testing.T) {
|
||||||
|
prev := project.Meta{
|
||||||
|
Backend: project.BackendTaskMaster,
|
||||||
|
TaskMaster: &project.TaskMaster{
|
||||||
|
BaseURL: "http://127.0.0.1:8080", Token: "tm_secret", ProjectID: "abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := views.NewProjectEditForm("inbox", prev, 80)
|
||||||
|
f.Inputs[views.FieldToken].SetValue("")
|
||||||
|
meta := f.Meta(prev)
|
||||||
|
if meta.TaskMaster == nil || meta.TaskMaster.Token != "tm_secret" {
|
||||||
|
t.Fatalf("expected kept tm token, got %+v", meta.TaskMaster)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,23 +12,37 @@ import (
|
|||||||
type ProjectItem struct {
|
type ProjectItem struct {
|
||||||
Project project.Project
|
Project project.Project
|
||||||
Active bool
|
Active bool
|
||||||
|
YouTrack bool // true when YouTrack sync is configured
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i ProjectItem) Title() string {
|
func (i ProjectItem) Title() string {
|
||||||
if i.Active {
|
name := i.Project.Name
|
||||||
return i.Project.Name + " *"
|
if i.YouTrack {
|
||||||
|
name += " [YT]"
|
||||||
}
|
}
|
||||||
return i.Project.Name
|
if i.Active {
|
||||||
|
return name + " *"
|
||||||
|
}
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
func (i ProjectItem) Description() string { return i.Project.ID }
|
func (i ProjectItem) Description() string { return i.Project.ID }
|
||||||
func (i ProjectItem) FilterValue() string { return i.Project.Name }
|
func (i ProjectItem) FilterValue() string { return i.Project.Name }
|
||||||
|
|
||||||
// NewProjectList builds a list of projects.
|
// NewProjectList builds a list of projects.
|
||||||
func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model {
|
func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model {
|
||||||
|
return NewProjectListWithYT(projects, activeID, nil, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProjectListWithYT builds a list, marking projects with YouTrack config.
|
||||||
|
func NewProjectListWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool, width, height int) list.Model {
|
||||||
items := make([]list.Item, len(projects))
|
items := make([]list.Item, len(projects))
|
||||||
selected := 0
|
selected := 0
|
||||||
for i, p := range projects {
|
for i, p := range projects {
|
||||||
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
|
items[i] = ProjectItem{
|
||||||
|
Project: p,
|
||||||
|
Active: p.ID == activeID,
|
||||||
|
YouTrack: ytConfigured[p.ID],
|
||||||
|
}
|
||||||
if p.ID == activeID {
|
if p.ID == activeID {
|
||||||
selected = i
|
selected = i
|
||||||
}
|
}
|
||||||
@@ -77,9 +91,14 @@ func JumpProjectByLetter(l *list.Model, r rune) bool {
|
|||||||
|
|
||||||
// ProjectsToItems converts projects to list items.
|
// ProjectsToItems converts projects to list items.
|
||||||
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
|
||||||
|
return ProjectsToItemsWithYT(projects, activeID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectsToItemsWithYT converts projects to list items with YouTrack flags.
|
||||||
|
func ProjectsToItemsWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool) []list.Item {
|
||||||
items := make([]list.Item, len(projects))
|
items := make([]list.Item, len(projects))
|
||||||
for i, p := range projects {
|
for i, p := range projects {
|
||||||
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
|
items[i] = ProjectItem{Project: p, Active: p.ID == activeID, YouTrack: ytConfigured[p.ID]}
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncAction is a sync menu choice.
|
||||||
|
type SyncAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncActionPull SyncAction = "pull"
|
||||||
|
SyncActionPush SyncAction = "push"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncOption is one row in the sync menu.
|
||||||
|
type SyncOption struct {
|
||||||
|
Action SyncAction
|
||||||
|
Label string
|
||||||
|
Shortcut rune
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncMenu renders a pull/push picker overlay.
|
||||||
|
type SyncMenu struct {
|
||||||
|
Cursor int
|
||||||
|
Width int
|
||||||
|
Title string
|
||||||
|
Options []SyncOption
|
||||||
|
Styles StatusMenuStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSyncMenu creates a sync action menu for the given backend label.
|
||||||
|
func NewSyncMenu(width int, styles StatusMenuStyles, backendLabel string) SyncMenu {
|
||||||
|
if backendLabel == "" {
|
||||||
|
backendLabel = "Sync"
|
||||||
|
}
|
||||||
|
opts := []SyncOption{
|
||||||
|
{Action: SyncActionPull, Label: "Pull from " + backendLabel, Shortcut: 'p'},
|
||||||
|
{Action: SyncActionPush, Label: "Push to " + backendLabel, Shortcut: 'u'},
|
||||||
|
}
|
||||||
|
return SyncMenu{Cursor: 0, Width: width, Title: backendLabel, Options: opts, Styles: styles}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDown moves the selection down.
|
||||||
|
func (m *SyncMenu) MoveDown() {
|
||||||
|
if m.Cursor < len(m.Options)-1 {
|
||||||
|
m.Cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveUp moves the selection up.
|
||||||
|
func (m *SyncMenu) MoveUp() {
|
||||||
|
if m.Cursor > 0 {
|
||||||
|
m.Cursor--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JumpToShortcut selects the option whose shortcut matches r.
|
||||||
|
func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
||||||
|
r = unicode.ToLower(r)
|
||||||
|
for i, opt := range m.Options {
|
||||||
|
if opt.Shortcut == r {
|
||||||
|
m.Cursor = i
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected returns the highlighted action.
|
||||||
|
func (m SyncMenu) Selected() SyncAction {
|
||||||
|
if m.Cursor < 0 || m.Cursor >= len(m.Options) {
|
||||||
|
return SyncActionPull
|
||||||
|
}
|
||||||
|
return m.Options[m.Cursor].Action
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the sync menu.
|
||||||
|
func (m SyncMenu) View() string {
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, m.Styles.Title.Render(m.Title))
|
||||||
|
for i, opt := range m.Options {
|
||||||
|
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
|
||||||
|
if i == m.Cursor {
|
||||||
|
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, m.Styles.Normal.Render(" "+entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := strings.Join(lines, "\n")
|
||||||
|
w := m.Width
|
||||||
|
if w < 20 {
|
||||||
|
w = 20
|
||||||
|
}
|
||||||
|
return m.Styles.Box.Width(min(44, w-2)).Render(body)
|
||||||
|
}
|
||||||
@@ -176,8 +176,12 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
|||||||
innerWidth := max(10, l.Width-4)
|
innerWidth := max(10, l.Width-4)
|
||||||
|
|
||||||
badge := l.statusBadge(t.Status)
|
badge := l.statusBadge(t.Status)
|
||||||
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2))
|
ext := ""
|
||||||
headerLine := badge + " " + l.Styles.Headline.Render(headline)
|
if id := t.DisplayID(); id != "" {
|
||||||
|
ext = lipgloss.NewStyle().Faint(true).Render(id) + " "
|
||||||
|
}
|
||||||
|
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
|
||||||
|
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
|
||||||
|
|
||||||
var content string
|
var content string
|
||||||
if selected {
|
if selected {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user