f3897907a3
- 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>
242 lines
6.9 KiB
Go
242 lines
6.9 KiB
Go
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
|
|
}
|