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)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package sync imports tickets from customer ticketing systems by polling
|
||||
// (§5.3) and exposes per-system connectors used for both syncing and the
|
||||
// admin "Test connection" button.
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
)
|
||||
|
||||
// Ticket is the system-agnostic shape a connector returns.
|
||||
type Ticket struct {
|
||||
Key string
|
||||
URL string
|
||||
Type string // epic | story | task
|
||||
Title string
|
||||
Description string
|
||||
Attachments []TicketAttachment
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
type TicketAttachment struct {
|
||||
Name string
|
||||
URL string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
// Connector talks to one customer's ticketing system.
|
||||
type Connector interface {
|
||||
// TestConnection performs a cheap authenticated call (§5.3).
|
||||
TestConnection(ctx context.Context) error
|
||||
// FetchUpdated returns tickets assigned to identity updated since the
|
||||
// watermark (callers pass lastSyncAt − 5 min for the overlap window).
|
||||
FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error)
|
||||
// ListAssignedKeys returns every ticket key currently assigned to
|
||||
// identity — used to flag orphaned tasks.
|
||||
ListAssignedKeys(ctx context.Context, identity string) ([]string, error)
|
||||
// Authorize adds the system's auth headers to an attachment download.
|
||||
Authorize(req *http.Request)
|
||||
}
|
||||
|
||||
// Credentials is the decrypted per-system credential map (§4.2 shapes).
|
||||
type Credentials map[string]string
|
||||
|
||||
// NewConnector builds the connector for a ticketing config + decrypted
|
||||
// credentials.
|
||||
func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Credentials) (Connector, error) {
|
||||
switch t {
|
||||
case domain.TicketingJira:
|
||||
if creds["email"] == "" || creds["apiToken"] == "" {
|
||||
return nil, fmt.Errorf("jira requires email and apiToken")
|
||||
}
|
||||
return &jiraConnector{baseURL: baseURL, projectKey: projectKey, email: creds["email"], apiToken: creds["apiToken"]}, nil
|
||||
case domain.TicketingAzure:
|
||||
if creds["organization"] == "" || creds["project"] == "" || creds["pat"] == "" {
|
||||
return nil, fmt.Errorf("azure_devops requires organization, project and pat")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://dev.azure.com"
|
||||
}
|
||||
return &azureConnector{baseURL: baseURL, organization: creds["organization"], project: creds["project"], pat: creds["pat"]}, nil
|
||||
case domain.TicketingYouTrack:
|
||||
if creds["permanentToken"] == "" {
|
||||
return nil, fmt.Errorf("youtrack requires permanentToken")
|
||||
}
|
||||
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil
|
||||
case domain.TicketingDemo:
|
||||
return &demoConnector{projectKey: projectKey}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown ticketing type %q", t)
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// getJSON performs an authorized GET and decodes JSON, with one retry on
|
||||
// 5xx/network errors (§12).
|
||||
func getJSON(ctx context.Context, authorize func(*http.Request), url string, dst any) error {
|
||||
return doJSON(ctx, authorize, http.MethodGet, url, nil, dst)
|
||||
}
|
||||
|
||||
func doJSON(ctx context.Context, authorize func(*http.Request), method, url string, body []byte, dst any) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
authorize(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("%s %s: status %d", method, url, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("%s %s: status %d: %.200s", method, url, resp.StatusCode, data)
|
||||
}
|
||||
if dst == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, dst); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", url, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func bodyReader(b []byte) io.Reader {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// demoConnector fabricates tickets locally (§11.11) so the entire
|
||||
// import→atomize→publish→claim flow is demonstrable offline. Deterministic:
|
||||
// the same project key always yields the same tickets. A project key ending
|
||||
// in "-ORPHAN" omits the last ticket from ListAssignedKeys to exercise the
|
||||
// orphan flow.
|
||||
type demoConnector struct {
|
||||
projectKey string
|
||||
}
|
||||
|
||||
var demoTickets = []struct {
|
||||
suffix int
|
||||
typ string
|
||||
title string
|
||||
description string
|
||||
}{
|
||||
{1, "epic", "Customer self-service portal",
|
||||
"Build a portal where customers can view invoices, update billing details, and download usage reports. Must integrate with the existing SSO and billing systems."},
|
||||
{2, "story", "User import from CSV",
|
||||
"As an admin I can bulk-import users from a CSV file with column mapping, validation preview, and an idempotent re-run option. Errors are reported per row."},
|
||||
{3, "story", "Email notification preferences",
|
||||
"As a user I can choose which events trigger email notifications (digest, immediate, off) and unsubscribe via a one-click link that does not require login."},
|
||||
{4, "task", "Rate-limit public API endpoints",
|
||||
"Add per-token rate limiting to all public API endpoints with sensible defaults, 429 responses with Retry-After, and metrics for limit hits."},
|
||||
{5, "task", "Fix timezone handling in reports",
|
||||
"Scheduled reports render dates in server time instead of the workspace timezone. Audit all report templates and normalize via the workspace setting."},
|
||||
}
|
||||
|
||||
func (d *demoConnector) Authorize(*http.Request) {}
|
||||
|
||||
func (d *demoConnector) TestConnection(context.Context) error { return nil }
|
||||
|
||||
func (d *demoConnector) key(i int) string {
|
||||
base := strings.TrimSuffix(strings.ToUpper(d.projectKey), "-ORPHAN")
|
||||
if base == "" {
|
||||
base = "DEMO"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
|
||||
func (d *demoConnector) FetchUpdated(_ context.Context, _ string, _ time.Time) ([]Ticket, error) {
|
||||
out := make([]Ticket, 0, len(demoTickets))
|
||||
for _, dt := range demoTickets {
|
||||
out = append(out, Ticket{
|
||||
Key: d.key(dt.suffix),
|
||||
URL: "https://demo.invalid/browse/" + d.key(dt.suffix),
|
||||
Type: dt.typ,
|
||||
Title: dt.title,
|
||||
Description: dt.description,
|
||||
Raw: map[string]any{"demo": true, "suffix": dt.suffix},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *demoConnector) ListAssignedKeys(context.Context, string) ([]string, error) {
|
||||
n := len(demoTickets)
|
||||
if strings.HasSuffix(strings.ToUpper(d.projectKey), "-ORPHAN") {
|
||||
n-- // last ticket "disappears" upstream → orphan flow
|
||||
}
|
||||
keys := make([]string, 0, n)
|
||||
for _, dt := range demoTickets[:n] {
|
||||
keys = append(keys, d.key(dt.suffix))
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user