feat: WeKan ticketing source

- wekan connector: board id as project key, cards assigned to the
  consultant's WeKan username (assignees with member fallback), archived
  cards skipped, client-side modifiedAt since-filtering, username/password
  login with token reuse (or pre-issued token), case-insensitive identity
- fake-WeKan unit tests: factory validation, test-connection, fetch
  filtering, key listing, token reuse
- admin customer wizard: WeKan option + credential fields
- OpenAPI enum + credential shapes, README identity mapping docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 21:15:34 +02:00
parent 6265ffa894
commit f3897907a3
11 changed files with 516 additions and 6 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ const (
TicketingJira TicketingType = "jira"
TicketingAzure TicketingType = "azure_devops"
TicketingYouTrack TicketingType = "youtrack"
TicketingWekan TicketingType = "wekan"
// TicketingDemo fabricates tickets locally so the whole flow works
// offline (§11.11).
TicketingDemo TicketingType = "demo"
@@ -15,7 +16,7 @@ const (
func (t TicketingType) Valid() bool {
switch t {
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo:
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingWekan, TicketingDemo:
return true
}
return false
+1 -1
View File
@@ -119,7 +119,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
return
}
if req.Type == nil || !req.Type.Valid() {
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo")
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack, wekan or demo")
return
}
settings, err := s.store.GetSettings(r.Context())
+17
View File
@@ -71,6 +71,23 @@ func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Cred
return nil, fmt.Errorf("youtrack requires permanentToken")
}
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil
case domain.TicketingWekan:
hasLogin := creds["username"] != "" && creds["password"] != ""
hasToken := creds["token"] != ""
if !hasLogin && !hasToken {
return nil, fmt.Errorf("wekan requires username+password (or a pre-issued token)")
}
if baseURL == "" {
return nil, fmt.Errorf("wekan requires baseUrl")
}
if projectKey == "" {
return nil, fmt.Errorf("wekan requires projectKey (the board id)")
}
return &wekanConnector{
baseURL: baseURL, boardID: projectKey,
username: creds["username"], password: creds["password"],
token: creds["token"],
}, nil
case domain.TicketingDemo:
return &demoConnector{projectKey: projectKey}, nil
default:
+241
View File
@@ -0,0 +1,241 @@
package sync
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// wekanConnector imports cards from a WeKan board. projectKey is the board
// id; the consultant's identity (users.extra.ticketingIdentities.wekan) is
// their WeKan username — cards assigned to that user (assignees, falling
// back to members) become tasks.
//
// Credentials: {"username": "...", "password": "..."} — the connector logs
// in per sync (WeKan tokens are short-lived); alternatively a pre-issued
// {"token": "...", "userId": "..."} pair is used directly.
type wekanConnector struct {
baseURL string
boardID string
username string
password string
mu sync.Mutex
token string
tokenAt time.Time
}
const wekanTokenTTL = 10 * time.Minute // re-login window for password auth
func (wk *wekanConnector) Authorize(req *http.Request) {
wk.mu.Lock()
defer wk.mu.Unlock()
if wk.token != "" {
req.Header.Set("Authorization", "Bearer "+wk.token)
}
}
// ensureAuth logs in with username/password unless a still-fresh (or
// pre-issued) token is available.
func (wk *wekanConnector) ensureAuth(ctx context.Context) error {
wk.mu.Lock()
haveFresh := wk.token != "" && (wk.password == "" || time.Since(wk.tokenAt) < wekanTokenTTL)
wk.mu.Unlock()
if haveFresh {
return nil
}
body, _ := json.Marshal(map[string]string{"username": wk.username, "password": wk.password})
var out struct {
ID string `json:"id"`
Token string `json:"token"`
}
err := doJSON(ctx, func(*http.Request) {}, http.MethodPost, wk.baseURL+"/users/login", body, &out)
if err != nil {
return fmt.Errorf("wekan login: %w", err)
}
if out.Token == "" {
return fmt.Errorf("wekan login: empty token in response")
}
wk.mu.Lock()
wk.token, wk.tokenAt = out.Token, time.Now()
wk.mu.Unlock()
return nil
}
func (wk *wekanConnector) TestConnection(ctx context.Context) error {
if err := wk.ensureAuth(ctx); err != nil {
return err
}
var board struct {
Title string `json:"title"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return fmt.Errorf("wekan board %s: %w", wk.boardID, err)
}
if board.Title == "" {
return fmt.Errorf("wekan board %s: not found or no access", wk.boardID)
}
return nil
}
type wekanBoard struct {
Title string `json:"title"`
Slug string `json:"slug"`
Members []struct {
UserID string `json:"userId"`
} `json:"members"`
}
type wekanCardDetail struct {
ID string `json:"_id"`
Title string `json:"title"`
Description string `json:"description"`
ListID string `json:"listId"`
Assignees []string `json:"assignees"`
Members []string `json:"members"`
ModifiedAt string `json:"modifiedAt"`
DateLastActivity string `json:"dateLastActivity"`
Archived bool `json:"archived"`
}
// resolveUserID maps the consultant's WeKan username to a board member's
// userId.
func (wk *wekanConnector) resolveUserID(ctx context.Context, board *wekanBoard, username string) (string, error) {
for _, m := range board.Members {
var u struct {
Username string `json:"username"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/users/"+m.UserID, &u); err != nil {
continue // non-admin tokens may not read every user; skip
}
if strings.EqualFold(u.Username, username) {
return m.UserID, nil
}
}
return "", fmt.Errorf("wekan user %q is not a member of board %s", username, wk.boardID)
}
// assignedCards walks lists → cards → card details and returns the cards
// assigned to userID (assignees, else members).
func (wk *wekanConnector) assignedCards(ctx context.Context, userID string) ([]wekanCardDetail, map[string]string, error) {
var lists []struct {
ID string `json:"_id"`
Title string `json:"title"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID+"/lists", &lists); err != nil {
return nil, nil, fmt.Errorf("wekan lists: %w", err)
}
listTitles := map[string]string{}
var out []wekanCardDetail
for _, list := range lists {
listTitles[list.ID] = list.Title
var cards []struct {
ID string `json:"_id"`
}
url := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards", wk.baseURL, wk.boardID, list.ID)
if err := getJSON(ctx, wk.Authorize, url, &cards); err != nil {
return nil, nil, fmt.Errorf("wekan cards of list %s: %w", list.ID, err)
}
for _, c := range cards {
var detail wekanCardDetail
detailURL := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards/%s", wk.baseURL, wk.boardID, list.ID, c.ID)
if err := getJSON(ctx, wk.Authorize, detailURL, &detail); err != nil {
return nil, nil, fmt.Errorf("wekan card %s: %w", c.ID, err)
}
if detail.Archived {
continue
}
assigned := detail.Assignees
if len(assigned) == 0 {
assigned = detail.Members
}
for _, a := range assigned {
if a == userID {
detail.ListID = list.ID
out = append(out, detail)
break
}
}
}
}
return out, listTitles, nil
}
func (wk *wekanConnector) cardTime(c wekanCardDetail) time.Time {
for _, raw := range []string{c.ModifiedAt, c.DateLastActivity} {
if raw == "" {
continue
}
if ts, err := time.Parse(time.RFC3339, raw); err == nil {
return ts
}
}
return time.Time{} // unknown → treat as always-updated
}
func (wk *wekanConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
if err := wk.ensureAuth(ctx); err != nil {
return nil, err
}
var board wekanBoard
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return nil, fmt.Errorf("wekan board: %w", err)
}
userID, err := wk.resolveUserID(ctx, &board, identity)
if err != nil {
return nil, err
}
cards, listTitles, err := wk.assignedCards(ctx, userID)
if err != nil {
return nil, err
}
slug := board.Slug
if slug == "" {
slug = "board"
}
out := []Ticket{}
for _, c := range cards {
if ts := wk.cardTime(c); !ts.IsZero() && ts.Before(since) {
continue
}
out = append(out, Ticket{
Key: c.ID,
URL: fmt.Sprintf("%s/b/%s/%s/%s", wk.baseURL, wk.boardID, slug, c.ID),
Type: "task", // kanban cards carry no epic/story hierarchy
Title: c.Title,
Description: c.Description,
Raw: map[string]any{
"board": board.Title, "list": listTitles[c.ListID], "cardId": c.ID,
},
})
}
return out, nil
}
func (wk *wekanConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
if err := wk.ensureAuth(ctx); err != nil {
return nil, err
}
var board wekanBoard
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return nil, fmt.Errorf("wekan board: %w", err)
}
userID, err := wk.resolveUserID(ctx, &board, identity)
if err != nil {
return nil, err
}
cards, _, err := wk.assignedCards(ctx, userID)
if err != nil {
return nil, err
}
keys := make([]string, len(cards))
for i, c := range cards {
keys[i] = c.ID
}
return keys, nil
}
+226
View File
@@ -0,0 +1,226 @@
package sync
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"bountyboard/internal/domain"
)
// fakeWekan implements just enough of the WeKan REST API: login, board with
// members, lists, cards, card details with assignees and timestamps.
func fakeWekan(t *testing.T, logins *atomic.Int64) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
authed := func(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("Authorization") != "Bearer wekan-token-1" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return false
}
return true
}
js := func(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
mux.HandleFunc("POST /users/login", func(w http.ResponseWriter, r *http.Request) {
var req struct{ Username, Password string }
json.NewDecoder(r.Body).Decode(&req)
if req.Username != "syncbot" || req.Password != "hunter2" {
http.Error(w, `{"error":"bad credentials"}`, http.StatusUnauthorized)
return
}
if logins != nil {
logins.Add(1)
}
js(w, map[string]string{"id": "u-syncbot", "token": "wekan-token-1"})
})
mux.HandleFunc("GET /api/boards/board1", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
js(w, map[string]any{
"title": "Sprint Board", "slug": "sprint-board",
"members": []map[string]any{
{"userId": "u-clara"}, {"userId": "u-other"}, {"userId": "u-syncbot"},
},
})
})
mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
names := map[string]string{"u-clara": "clara", "u-other": "othello", "u-syncbot": "syncbot"}
js(w, map[string]string{"username": names[r.PathValue("id")]})
})
mux.HandleFunc("GET /api/boards/board1/lists", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
js(w, []map[string]string{{"_id": "l-todo", "title": "Todo"}, {"_id": "l-doing", "title": "Doing"}})
})
mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
switch r.PathValue("list") {
case "l-todo":
js(w, []map[string]string{{"_id": "card-a"}, {"_id": "card-b"}, {"_id": "card-d"}})
default:
js(w, []map[string]string{{"_id": "card-c"}})
}
})
cards := map[string]map[string]any{
// assigned to clara, recently modified
"card-a": {"_id": "card-a", "title": "Fix the login flow", "description": "details A",
"assignees": []string{"u-clara"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
// member-fallback assignment, old timestamp
"card-b": {"_id": "card-b", "title": "Old card", "description": "details B",
"assignees": []string{}, "members": []string{"u-clara"},
"modifiedAt": "2020-01-01T00:00:00Z"},
// assigned to someone else
"card-c": {"_id": "card-c", "title": "Not clara's", "description": "details C",
"assignees": []string{"u-other"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
// clara's but archived
"card-d": {"_id": "card-d", "title": "Archived card", "description": "details D",
"assignees": []string{"u-clara"}, "archived": true,
"modifiedAt": time.Now().UTC().Format(time.RFC3339)},
}
mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards/{card}", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
c, ok := cards[r.PathValue("card")]
if !ok {
http.NotFound(w, r)
return
}
js(w, c)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newWekan(t *testing.T, srv *httptest.Server) Connector {
t.Helper()
conn, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
Credentials{"username": "syncbot", "password": "hunter2"})
if err != nil {
t.Fatal(err)
}
return conn
}
func TestWekanFactoryValidation(t *testing.T) {
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", Credentials{}); err == nil {
t.Fatal("missing credentials must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "", "board1",
Credentials{"username": "u", "password": "p"}); err == nil {
t.Fatal("missing baseUrl must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "",
Credentials{"username": "u", "password": "p"}); err == nil {
t.Fatal("missing board id must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1",
Credentials{"token": "tok", "userId": "u1"}); err != nil {
t.Fatalf("pre-issued token must be accepted: %v", err)
}
}
func TestWekanTestConnection(t *testing.T) {
srv := fakeWekan(t, nil)
if err := newWekan(t, srv).TestConnection(context.Background()); err != nil {
t.Fatalf("test connection: %v", err)
}
bad, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
Credentials{"username": "syncbot", "password": "wrong"})
if err != nil {
t.Fatal(err)
}
if err := bad.TestConnection(context.Background()); err == nil {
t.Fatal("wrong password must fail the connection test")
}
}
func TestWekanFetchUpdated(t *testing.T) {
srv := fakeWekan(t, nil)
conn := newWekan(t, srv)
// since the epoch: clara gets card-a (assignee) and card-b (member
// fallback); card-c is someone else's, card-d is archived
tickets, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0))
if err != nil {
t.Fatal(err)
}
if len(tickets) != 2 {
t.Fatalf("tickets = %d, want 2 (%+v)", len(tickets), tickets)
}
byKey := map[string]Ticket{}
for _, tk := range tickets {
byKey[tk.Key] = tk
}
a, ok := byKey["card-a"]
if !ok {
t.Fatal("card-a missing")
}
if a.Title != "Fix the login flow" || a.Type != "task" {
t.Fatalf("card-a mapped wrong: %+v", a)
}
if a.URL != srv.URL+"/b/board1/sprint-board/card-a" {
t.Fatalf("card url: %s", a.URL)
}
if a.Raw["list"] != "Todo" || a.Raw["board"] != "Sprint Board" {
t.Fatalf("raw payload: %+v", a.Raw)
}
// recent since-watermark filters out the old card-b
tickets, err = conn.FetchUpdated(context.Background(), "clara", time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if len(tickets) != 1 || tickets[0].Key != "card-a" {
t.Fatalf("since filter: %+v", tickets)
}
// identity casing is forgiving
if _, err := conn.FetchUpdated(context.Background(), "CLARA", time.Unix(0, 0)); err != nil {
t.Fatalf("case-insensitive identity: %v", err)
}
// unknown identity errors clearly
if _, err := conn.FetchUpdated(context.Background(), "nobody", time.Unix(0, 0)); err == nil {
t.Fatal("unknown wekan user must error")
}
}
func TestWekanListAssignedKeysAndTokenReuse(t *testing.T) {
var logins atomic.Int64
srv := fakeWekan(t, &logins)
conn := newWekan(t, srv)
keys, err := conn.ListAssignedKeys(context.Background(), "clara")
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Fatalf("keys = %v, want card-a + card-b", keys)
}
// several operations reuse one login token instead of re-authenticating
if _, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)); err != nil {
t.Fatal(err)
}
if logins.Load() != 1 {
t.Fatalf("logins = %d, want 1 (token reuse)", logins.Load())
}
}