feat: admin area with customers, users, settings, audit log, service status (phase 5)
- 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>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jiraConnector talks to Jira Cloud REST v3 with email+API-token basic auth.
|
||||
type jiraConnector struct {
|
||||
baseURL string
|
||||
projectKey string
|
||||
email string
|
||||
apiToken string
|
||||
}
|
||||
|
||||
func (j *jiraConnector) Authorize(req *http.Request) {
|
||||
req.SetBasicAuth(j.email, j.apiToken)
|
||||
}
|
||||
|
||||
func (j *jiraConnector) TestConnection(ctx context.Context) error {
|
||||
var me struct {
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
if err := getJSON(ctx, j.Authorize, j.baseURL+"/rest/api/3/myself", &me); err != nil {
|
||||
return fmt.Errorf("jira /myself: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type jiraIssue struct {
|
||||
Key string `json:"key"`
|
||||
Fields struct {
|
||||
Summary string `json:"summary"`
|
||||
Description map[string]any `json:"description"` // ADF document
|
||||
IssueType struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"issuetype"`
|
||||
Attachment []struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"attachment"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
|
||||
func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) {
|
||||
var all []jiraIssue
|
||||
for startAt := 0; ; {
|
||||
var page struct {
|
||||
Issues []jiraIssue `json:"issues"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d",
|
||||
j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt)
|
||||
if err := getJSON(ctx, j.Authorize, u, &page); err != nil {
|
||||
return nil, fmt.Errorf("jira search: %w", err)
|
||||
}
|
||||
all = append(all, page.Issues...)
|
||||
startAt += len(page.Issues)
|
||||
if len(page.Issues) == 0 || startAt >= page.Total {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (j *jiraConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
|
||||
jql := fmt.Sprintf(`assignee = %q AND project = %q AND updated >= %q ORDER BY updated ASC`,
|
||||
identity, j.projectKey, since.UTC().Format("2006/01/02 15:04"))
|
||||
issues, err := j.search(ctx, jql, "summary,description,issuetype,attachment")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Ticket, 0, len(issues))
|
||||
for _, is := range issues {
|
||||
t := Ticket{
|
||||
Key: is.Key,
|
||||
URL: j.baseURL + "/browse/" + is.Key,
|
||||
Type: jiraType(is.Fields.IssueType.Name),
|
||||
Title: is.Fields.Summary,
|
||||
Description: adfToText(is.Fields.Description),
|
||||
Raw: map[string]any{"key": is.Key, "issueType": is.Fields.IssueType.Name},
|
||||
}
|
||||
for _, a := range is.Fields.Attachment {
|
||||
t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Filename, URL: a.Content, MimeType: a.MimeType})
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (j *jiraConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
|
||||
jql := fmt.Sprintf(`assignee = %q AND project = %q`, identity, j.projectKey)
|
||||
issues, err := j.search(ctx, jql, "key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, len(issues))
|
||||
for i, is := range issues {
|
||||
keys[i] = is.Key
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func jiraType(name string) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "epic":
|
||||
return "epic"
|
||||
case "story", "user story":
|
||||
return "story"
|
||||
default:
|
||||
return "task"
|
||||
}
|
||||
}
|
||||
|
||||
// adfToText flattens an Atlassian Document Format tree into plain text —
|
||||
// good enough for atomization input.
|
||||
func adfToText(node map[string]any) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
var walk func(n map[string]any)
|
||||
walk = func(n map[string]any) {
|
||||
if text, ok := n["text"].(string); ok {
|
||||
sb.WriteString(text)
|
||||
}
|
||||
if t, _ := n["type"].(string); t == "paragraph" || t == "heading" || t == "listItem" {
|
||||
defer sb.WriteString("\n")
|
||||
}
|
||||
if content, ok := n["content"].([]any); ok {
|
||||
for _, child := range content {
|
||||
if m, ok := child.(map[string]any); ok {
|
||||
walk(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
Reference in New Issue
Block a user