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 }