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,166 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// azureConnector talks to Azure DevOps REST 7.1 with PAT basic auth.
|
||||
type azureConnector struct {
|
||||
baseURL string // https://dev.azure.com unless self-hosted
|
||||
organization string
|
||||
project string
|
||||
pat string
|
||||
}
|
||||
|
||||
func (a *azureConnector) Authorize(req *http.Request) {
|
||||
req.SetBasicAuth("", a.pat)
|
||||
}
|
||||
|
||||
func (a *azureConnector) orgURL() string {
|
||||
return strings.TrimRight(a.baseURL, "/") + "/" + a.organization
|
||||
}
|
||||
|
||||
func (a *azureConnector) TestConnection(ctx context.Context) error {
|
||||
var out struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := getJSON(ctx, a.Authorize, a.orgURL()+"/_apis/projects?api-version=7.1", &out); err != nil {
|
||||
return fmt.Errorf("azure devops /_apis/projects: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *azureConnector) wiql(ctx context.Context, query string) ([]int, error) {
|
||||
body, _ := json.Marshal(map[string]string{"query": query})
|
||||
var out struct {
|
||||
WorkItems []struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"workItems"`
|
||||
}
|
||||
u := fmt.Sprintf("%s/%s/_apis/wit/wiql?api-version=7.1", a.orgURL(), a.project)
|
||||
if err := doJSON(ctx, a.Authorize, http.MethodPost, u, body, &out); err != nil {
|
||||
return nil, fmt.Errorf("azure wiql: %w", err)
|
||||
}
|
||||
ids := make([]int, len(out.WorkItems))
|
||||
for i, wi := range out.WorkItems {
|
||||
ids[i] = wi.ID
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
type azureWorkItem struct {
|
||||
ID int `json:"id"`
|
||||
Fields map[string]any `json:"fields"`
|
||||
Links struct {
|
||||
HTML struct {
|
||||
Href string `json:"href"`
|
||||
} `json:"html"`
|
||||
} `json:"_links"`
|
||||
Relations []struct {
|
||||
Rel string `json:"rel"`
|
||||
URL string `json:"url"`
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"attributes"`
|
||||
} `json:"relations"`
|
||||
}
|
||||
|
||||
func (a *azureConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
|
||||
q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s' AND [System.ChangedDate] >= '%s'`,
|
||||
identity, a.project, since.UTC().Format("2006-01-02T15:04:05Z"))
|
||||
ids, err := a.wiql(ctx, q)
|
||||
if err != nil || len(ids) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Ticket
|
||||
for start := 0; start < len(ids); start += 200 {
|
||||
end := min(start+200, len(ids))
|
||||
idStrs := make([]string, 0, end-start)
|
||||
for _, id := range ids[start:end] {
|
||||
idStrs = append(idStrs, fmt.Sprint(id))
|
||||
}
|
||||
var batch struct {
|
||||
Value []azureWorkItem `json:"value"`
|
||||
}
|
||||
u := fmt.Sprintf("%s/%s/_apis/wit/workitems?ids=%s&$expand=all&api-version=7.1",
|
||||
a.orgURL(), a.project, strings.Join(idStrs, ","))
|
||||
if err := getJSON(ctx, a.Authorize, u, &batch); err != nil {
|
||||
return nil, fmt.Errorf("azure workitems batch: %w", err)
|
||||
}
|
||||
for _, wi := range batch.Value {
|
||||
out = append(out, a.toTicket(wi))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *azureConnector) toTicket(wi azureWorkItem) Ticket {
|
||||
title, _ := wi.Fields["System.Title"].(string)
|
||||
descHTML, _ := wi.Fields["System.Description"].(string)
|
||||
witype, _ := wi.Fields["System.WorkItemType"].(string)
|
||||
url := wi.Links.HTML.Href
|
||||
if url == "" {
|
||||
url = fmt.Sprintf("%s/%s/_workitems/edit/%d", a.orgURL(), a.project, wi.ID)
|
||||
}
|
||||
t := Ticket{
|
||||
Key: fmt.Sprintf("%s-%d", strings.ToUpper(a.project), wi.ID),
|
||||
URL: url,
|
||||
Type: azureType(witype),
|
||||
Title: title,
|
||||
Description: stripHTML(descHTML),
|
||||
Raw: map[string]any{"id": wi.ID, "workItemType": witype},
|
||||
}
|
||||
for _, rel := range wi.Relations {
|
||||
if rel.Rel == "AttachedFile" {
|
||||
t.Attachments = append(t.Attachments, TicketAttachment{Name: rel.Attributes.Name, URL: rel.URL})
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (a *azureConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
|
||||
q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s'`,
|
||||
identity, a.project)
|
||||
ids, err := a.wiql(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
keys[i] = fmt.Sprintf("%s-%d", strings.ToUpper(a.project), id)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func azureType(name string) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "epic":
|
||||
return "epic"
|
||||
case "user story", "product backlog item":
|
||||
return "story"
|
||||
default:
|
||||
return "task"
|
||||
}
|
||||
}
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// stripHTML is a lossy HTML→text flattener for ADO descriptions.
|
||||
func stripHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "<br>", "\n")
|
||||
s = strings.ReplaceAll(s, "<br/>", "\n")
|
||||
s = strings.ReplaceAll(s, "</p>", "\n")
|
||||
s = tagRe.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
Reference in New Issue
Block a user