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:
@@ -137,3 +137,16 @@ Spec-silent choices, recorded as required by the build instructions.
|
|||||||
- **`scripts/acceptance.sh`** automates the §13 checklist live (import →
|
- **`scripts/acceptance.sh`** automates the §13 checklist live (import →
|
||||||
subdivide → extend → publish → claim/decline/approve → review → award →
|
subdivide → extend → publish → claim/decline/approve → review → award →
|
||||||
AI job via real Claude Code → breaker independence) and is re-run-safe.
|
AI job via real Claude Code → breaker independence) and is re-run-safe.
|
||||||
|
|
||||||
|
## Post-deploy additions
|
||||||
|
|
||||||
|
- **WeKan connector**: `projectKey` is the board id; "assigned to the
|
||||||
|
consultant" means the consultant's WeKan username (from
|
||||||
|
`ticketingIdentities.wekan`) appears in a card's `assignees` (falling back
|
||||||
|
to `members` when no assignee is set). Archived cards are skipped. WeKan
|
||||||
|
has no epic/story hierarchy → all cards map to type `task`; the API offers
|
||||||
|
no server-side updated-since filter, so the connector filters on
|
||||||
|
`modifiedAt` client-side (one details request per card — fine for board
|
||||||
|
sizes WeKan handles). Auth: username+password login per sync with a 10-min
|
||||||
|
token reuse window, or a pre-issued token. Card attachments are not
|
||||||
|
imported in v1.
|
||||||
|
|||||||
@@ -14,3 +14,4 @@
|
|||||||
- Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks.
|
- Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks.
|
||||||
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
|
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
|
||||||
- Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks.
|
- Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks.
|
||||||
|
- Post-deploy: ANTHROPIC_API_KEY wired into .env (gitignored) — atomizer now produces real claude-sonnet-4-6 subdivisions (verified live, sum=1.0). Added WeKan ticketing source: connector (login or pre-issued token, board=projectKey, cards assigned to consultant's wekan username w/ member fallback, archived skipped, modifiedAt since-filter, token reuse), fake-server unit tests, admin wizard fields, OpenAPI/README updates.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Bounty Board
|
# Bounty Board
|
||||||
|
|
||||||
A consulting work-management platform: imports tickets from Jira / Azure
|
A consulting work-management platform: imports tickets from Jira / Azure
|
||||||
DevOps / YouTrack (or an offline `demo` source), lets **consultants** atomize
|
DevOps / YouTrack / WeKan (or an offline `demo` source), lets **consultants** atomize
|
||||||
them into small developer tasks with AI assistance, publishes them on a
|
them into small developer tasks with AI assistance, publishes them on a
|
||||||
**bounty board** where **developers** claim work (or an **AI work performer**
|
**bounty board** where **developers** claim work (or an **AI work performer**
|
||||||
does it), and tracks review, approval, and bounty-based metrics.
|
does it), and tracks review, approval, and bounty-based metrics.
|
||||||
@@ -176,7 +176,9 @@ limits, and the mock services' Anthropic settings.
|
|||||||
|
|
||||||
Consultants map their ticketing identities in their profile via
|
Consultants map their ticketing identities in their profile via
|
||||||
`users.extra.ticketingIdentities` (e.g. set by an admin):
|
`users.extra.ticketingIdentities` (e.g. set by an admin):
|
||||||
`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login"}`.
|
`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login", "wekan": "wekan-username"}`.
|
||||||
|
For WeKan the customer's project key is the **board id** and cards assigned
|
||||||
|
to the consultant (assignees, falling back to members) are imported.
|
||||||
The `demo` ticketing type needs no identity.
|
The `demo` ticketing type needs no identity.
|
||||||
|
|
||||||
## Deliberately out of scope (documented stubs, §11.24)
|
## Deliberately out of scope (documented stubs, §11.24)
|
||||||
|
|||||||
+2
-2
@@ -269,7 +269,7 @@ components:
|
|||||||
required: [name, type]
|
required: [name, type]
|
||||||
properties:
|
properties:
|
||||||
name: {type: string}
|
name: {type: string}
|
||||||
type: {type: string, enum: [jira, azure_devops, youtrack, demo]}
|
type: {type: string, enum: [jira, azure_devops, youtrack, wekan, demo]}
|
||||||
baseUrl: {type: string}
|
baseUrl: {type: string}
|
||||||
projectKey: {type: string}
|
projectKey: {type: string}
|
||||||
pollIntervalSec: {type: integer, minimum: 10}
|
pollIntervalSec: {type: integer, minimum: 10}
|
||||||
@@ -277,7 +277,7 @@ components:
|
|||||||
consultantIds: {type: array, items: {type: string}}
|
consultantIds: {type: array, items: {type: string}}
|
||||||
credentials:
|
credentials:
|
||||||
type: object
|
type: object
|
||||||
description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · demo: {}"
|
description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · wekan: {username, password} (projectKey = board id) · demo: {}"
|
||||||
schemas:
|
schemas:
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const (
|
|||||||
TicketingJira TicketingType = "jira"
|
TicketingJira TicketingType = "jira"
|
||||||
TicketingAzure TicketingType = "azure_devops"
|
TicketingAzure TicketingType = "azure_devops"
|
||||||
TicketingYouTrack TicketingType = "youtrack"
|
TicketingYouTrack TicketingType = "youtrack"
|
||||||
|
TicketingWekan TicketingType = "wekan"
|
||||||
// TicketingDemo fabricates tickets locally so the whole flow works
|
// TicketingDemo fabricates tickets locally so the whole flow works
|
||||||
// offline (§11.11).
|
// offline (§11.11).
|
||||||
TicketingDemo TicketingType = "demo"
|
TicketingDemo TicketingType = "demo"
|
||||||
@@ -15,7 +16,7 @@ const (
|
|||||||
|
|
||||||
func (t TicketingType) Valid() bool {
|
func (t TicketingType) Valid() bool {
|
||||||
switch t {
|
switch t {
|
||||||
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo:
|
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingWekan, TicketingDemo:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Type == nil || !req.Type.Valid() {
|
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
|
return
|
||||||
}
|
}
|
||||||
settings, err := s.store.GetSettings(r.Context())
|
settings, err := s.store.GetSettings(r.Context())
|
||||||
|
|||||||
@@ -71,6 +71,23 @@ func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Cred
|
|||||||
return nil, fmt.Errorf("youtrack requires permanentToken")
|
return nil, fmt.Errorf("youtrack requires permanentToken")
|
||||||
}
|
}
|
||||||
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil
|
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:
|
case domain.TicketingDemo:
|
||||||
return &demoConnector{projectKey: projectKey}, nil
|
return &demoConnector{projectKey: projectKey}, nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ function credsFromForm(type) {
|
|||||||
return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
|
return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
|
||||||
case 'youtrack':
|
case 'youtrack':
|
||||||
return { permanentToken: val('c-yt-token') };
|
return { permanentToken: val('c-yt-token') };
|
||||||
|
case 'wekan':
|
||||||
|
return { username: val('c-wekan-user'), password: val('c-wekan-pass') };
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<option value="jira">Jira Cloud</option>
|
<option value="jira">Jira Cloud</option>
|
||||||
<option value="azure_devops">Azure DevOps</option>
|
<option value="azure_devops">Azure DevOps</option>
|
||||||
<option value="youtrack">YouTrack</option>
|
<option value="youtrack">YouTrack</option>
|
||||||
|
<option value="wekan">WeKan</option>
|
||||||
<option value="demo">Demo (offline)</option>
|
<option value="demo">Demo (offline)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,6 +59,12 @@
|
|||||||
<legend>YouTrack credentials</legend>
|
<legend>YouTrack credentials</legend>
|
||||||
<div class="field"><label for="c-yt-token">Permanent token</label><input type="password" id="c-yt-token" autocomplete="off"></div>
|
<div class="field"><label for="c-yt-token">Permanent token</label><input type="password" id="c-yt-token" autocomplete="off"></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset id="creds-wekan" class="creds" hidden>
|
||||||
|
<legend>WeKan credentials</legend>
|
||||||
|
<p class="hint">Project key above = the WeKan board id; the connector imports cards assigned to each consultant's WeKan username.</p>
|
||||||
|
<div class="field"><label for="c-wekan-user">Username</label><input type="text" id="c-wekan-user" autocomplete="off"></div>
|
||||||
|
<div class="field"><label for="c-wekan-pass">Password</label><input type="password" id="c-wekan-pass" autocomplete="off"></div>
|
||||||
|
</fieldset>
|
||||||
<fieldset id="creds-demo" class="creds" hidden>
|
<fieldset id="creds-demo" class="creds" hidden>
|
||||||
<legend>Demo</legend>
|
<legend>Demo</legend>
|
||||||
<p class="hint">No credentials required — tickets are fabricated locally.</p>
|
<p class="hint">No credentials required — tickets are fabricated locally.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user