458ba6a7ee
- customer CRUD wizard backend: per-system credential shapes validated via
connector construction, AES-256-GCM at rest, write-only over the API
- ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection
- user management: roles, disable (revokes sessions), force password reset,
delete; self-demotion/disable/delete guards
- admin-editable runtime settings ({_id:app}) incl. atomizer URL override
- audit log written on every privileged mutation + filterable list API
- service status panel: mongo/atomizer/work-performer health + latency with
late-bound breaker, sync and jobs status providers
- tabbed admin UI (customers wizard dialog, users table, settings, status, audit)
- compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
3.4 KiB
Go
132 lines
3.4 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// youtrackConnector talks to YouTrack's REST API with a permanent token.
|
|
type youtrackConnector struct {
|
|
baseURL string
|
|
projectKey string
|
|
token string
|
|
}
|
|
|
|
func (y *youtrackConnector) Authorize(req *http.Request) {
|
|
req.Header.Set("Authorization", "Bearer "+y.token)
|
|
}
|
|
|
|
func (y *youtrackConnector) TestConnection(ctx context.Context) error {
|
|
var me struct {
|
|
Login string `json:"login"`
|
|
}
|
|
if err := getJSON(ctx, y.Authorize, y.baseURL+"/api/users/me?fields=login", &me); err != nil {
|
|
return fmt.Errorf("youtrack /api/users/me: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type ytIssue struct {
|
|
IDReadable string `json:"idReadable"`
|
|
Summary string `json:"summary"`
|
|
Description string `json:"description"`
|
|
Attachments []struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
MimeType string `json:"mimeType"`
|
|
} `json:"attachments"`
|
|
CustomFields []struct {
|
|
Name string `json:"name"`
|
|
Value any `json:"value"`
|
|
} `json:"customFields"`
|
|
}
|
|
|
|
func (y *youtrackConnector) query(ctx context.Context, q, fields string) ([]ytIssue, error) {
|
|
var all []ytIssue
|
|
for skip := 0; ; skip += 100 {
|
|
var page []ytIssue
|
|
u := fmt.Sprintf("%s/api/issues?query=%s&fields=%s&$top=100&$skip=%d",
|
|
y.baseURL, url.QueryEscape(q), url.QueryEscape(fields), skip)
|
|
if err := getJSON(ctx, y.Authorize, u, &page); err != nil {
|
|
return nil, fmt.Errorf("youtrack issues: %w", err)
|
|
}
|
|
all = append(all, page...)
|
|
if len(page) < 100 {
|
|
return all, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
const ytFields = "idReadable,summary,description,attachments(name,url,mimeType),customFields(name,value(name))"
|
|
|
|
func (y *youtrackConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
|
|
q := fmt.Sprintf("assignee: %s project: %s updated: %s .. *",
|
|
identity, y.projectKey, since.UTC().Format("2006-01-02T15:04"))
|
|
issues, err := y.query(ctx, q, ytFields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Ticket, 0, len(issues))
|
|
for _, is := range issues {
|
|
out = append(out, y.toTicket(is))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (y *youtrackConnector) toTicket(is ytIssue) Ticket {
|
|
issueType := "task"
|
|
for _, cf := range is.CustomFields {
|
|
if cf.Name == "Type" {
|
|
if v, ok := cf.Value.(map[string]any); ok {
|
|
if name, ok := v["name"].(string); ok {
|
|
issueType = ytType(name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
t := Ticket{
|
|
Key: is.IDReadable,
|
|
URL: y.baseURL + "/issue/" + is.IDReadable,
|
|
Type: issueType,
|
|
Title: is.Summary,
|
|
Description: is.Description,
|
|
Raw: map[string]any{"idReadable": is.IDReadable},
|
|
}
|
|
for _, a := range is.Attachments {
|
|
u := a.URL
|
|
if strings.HasPrefix(u, "/") {
|
|
u = y.baseURL + u
|
|
}
|
|
t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Name, URL: u, MimeType: a.MimeType})
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (y *youtrackConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
|
|
q := fmt.Sprintf("assignee: %s project: %s", identity, y.projectKey)
|
|
issues, err := y.query(ctx, q, "idReadable")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys := make([]string, len(issues))
|
|
for i, is := range issues {
|
|
keys[i] = is.IDReadable
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func ytType(name string) string {
|
|
switch strings.ToLower(name) {
|
|
case "epic":
|
|
return "epic"
|
|
case "story", "user story", "feature":
|
|
return "story"
|
|
default:
|
|
return "task"
|
|
}
|
|
}
|