package atomize import ( "context" "fmt" "math" "net/http" "strings" "time" "unicode/utf8" "bountyboard/internal/extsvc" ) // Backend for the Anypreta "Issue Atomizer" API (OpenAPI 3.1, v0.1.0). Its // model is two-level — issue → features → tasks — where ours is one-level // (task → children), and only *features* carry an effort weight // (`estimation`); its tasks carry none at all. A board child therefore maps to // a Feature and Subdivide calls POST /v1/atomize-issue. // // Extend and Summarize have no counterpart here (/v1/extend-feature-tasks adds // tasks *inside* one feature and returns the whole set), so those stay on the // §5.1 service. The API declares no security scheme; it authenticates with // Authorization: Bearer . // Field caps from the spec — exceeded values are 422s, so trim rather than fail. const ( maxIssueLen = 50000 maxGuidanceLen = 10000 maxSystemIDLen = 256 maxSystemDesc = 2000 ) type apComponent struct { ID string `json:"id"` Description string `json:"description"` SubComponents []apComponent `json:"sub_components,omitempty"` } type apSystem struct { ID string `json:"id"` Description string `json:"description"` Components []apComponent `json:"components,omitempty"` } type apAtomizeIssueRequest struct { System apSystem `json:"system"` Issue string `json:"issue"` GuidanceNote string `json:"guidance_note,omitempty"` } type apCriterion struct { Description string `json:"description"` } type apFeature struct { ID string `json:"id"` Title string `json:"title"` StakeholderSummary string `json:"stakeholder_summary"` Description string `json:"description"` AcceptanceCriteria []apCriterion `json:"acceptance_criteria"` Priority int `json:"priority"` RelatedComponents []string `json:"related_components"` DependsOn []string `json:"depends_on"` // Estimation is optional and nullable in the spec; observed to sum to 1 // across a feature set, but that is not promised. Estimation *float64 `json:"estimation"` } type apAtomizeIssueResponse struct { FeatureSet struct { Features []apFeature `json:"features"` } `json:"feature_set"` } // remote is the Anypreta-backed Atomize path. It reuses extsvc for the bearer // auth, retry and circuit breaker; the base URL carries the /v1 prefix so // extsvc's /healthz probe lands on the API's /v1/healthz. type remote struct { c *extsvc.Client } func newRemote(baseURL, apiKey string, timeout time.Duration) *remote { base := strings.TrimRight(baseURL, "/") + "/v1" return &remote{c: extsvc.NewClient("atomizer-remote", func() string { return base }, apiKey, timeout)} } // Atomize maps a subdivide request onto POST /v1/atomize-issue and maps the // returned features back onto board children. func (r *remote) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) { body := apAtomizeIssueRequest{ System: systemFor(req), Issue: truncate(issueText(req), maxIssueLen), GuidanceNote: truncate(guidanceNote(req), maxGuidanceLen), } var resp apAtomizeIssueResponse if err := r.c.Call(ctx, http.MethodPost, "/atomize-issue", body, &resp); err != nil { return nil, err } features := resp.FeatureSet.Features if len(features) == 0 { return nil, fmt.Errorf("%w: empty feature set", ErrBadCoefficients) } coeffs, estimated, err := featureCoefficients(features) if err != nil { return nil, err } tasks := make([]SubTask, len(features)) for i, f := range features { criteria := make([]string, 0, len(f.AcceptanceCriteria)) for _, c := range f.AcceptanceCriteria { if d := strings.TrimSpace(c.Description); d != "" { criteria = append(criteria, d) } } tasks[i] = SubTask{ Title: strings.TrimSpace(f.Title), Description: strings.TrimSpace(f.Description), AcceptanceCriteria: criteria, EffortCoefficient: coeffs[i], } } out := &AtomizeResponse{Tasks: tasks, Model: "anypreta/issue-atomizer"} if !estimated { out.Notes = "The atomizer returned no effort estimation, so the budget was split evenly across the tasks." } return out, nil } // featureCoefficients turns feature estimations into effort coefficients // summing to exactly 1. Estimations are optional in the spec, so an incomplete // set falls back to an even split (reported via the second return value) // rather than failing the job — bounties still need *some* weight. func featureCoefficients(features []apFeature) ([]float64, bool, error) { raw := make([]float64, len(features)) estimated := true for i, f := range features { if f.Estimation == nil || *f.Estimation <= 0 || math.IsNaN(*f.Estimation) { estimated = false break } raw[i] = *f.Estimation } if !estimated { even := 1.0 / float64(len(features)) for i := range raw { raw[i] = even } } norm, err := normalizeToOne(raw) if err != nil { return nil, false, err } return norm, estimated, nil } // normalizeToOne rescales positive weights proportionally so they sum to // exactly 1. Unlike NormalizeCoefficients (§5.1) it accepts any positive sum: // this API never promises a normalized feature set, so a deviation is expected // rather than a service fault. func normalizeToOne(weights []float64) ([]float64, error) { if len(weights) == 0 { return nil, fmt.Errorf("%w: empty", ErrBadCoefficients) } sum := 0.0 for _, w := range weights { if math.IsNaN(w) || math.IsInf(w, 0) || w <= 0 { return nil, fmt.Errorf("%w: weight %v is not positive", ErrBadCoefficients, w) } sum += w } out := make([]float64, len(weights)) largest, total := 0, 0.0 for i, w := range weights { out[i] = math.Round(w/sum*10000) / 10000 total += out[i] if out[i] > out[largest] { largest = i } } out[largest] = math.Round((out[largest]+1-total)*10000) / 10000 return out, nil } // defaultComponents is the component tree we send for every customer. // // The spec marks `components` optional, but the service only emits features for // components it considers *in scope*: an empty tree yields zero features and a // 422 ("no in-scope components produced features"). The board has no component // model of its own, so we send a generic tree covering the usual surfaces of a // software change. Components irrelevant to a ticket simply produce nothing, so // a broad tree costs nothing and a narrow one under-splits — a single catch-all // component collapses every ticket into one feature. var defaultComponents = []apComponent{ {ID: "frontend", Description: "User interface: pages, views, client-side behaviour and styling."}, {ID: "backend", Description: "Server-side application logic, APIs and background jobs."}, {ID: "data", Description: "Data storage: schema, migrations, queries and data integrity."}, {ID: "integrations", Description: "Integrations with external systems, third-party APIs and notifications."}, } // systemFor derives the required system object from the customer, which is the // only project-level context the board has. func systemFor(req AtomizeRequest) apSystem { id := strings.TrimSpace(req.CustomerID) if id == "" { id = "bountyboard" } name := strings.TrimSpace(req.CustomerName) if name == "" { name = "this project" } return apSystem{ ID: truncate(id, maxSystemIDLen), Description: truncate(fmt.Sprintf("Software project %q. Work is delivered as small, independently deliverable tasks picked up by freelance developers.", name), maxSystemDesc), Components: defaultComponents, } } // issueText flattens the ticket into the single `issue` string the API takes — // it has no fields for acceptance criteria, links or attachments. func issueText(req AtomizeRequest) string { var b strings.Builder if t := strings.TrimSpace(req.Title); t != "" { fmt.Fprintf(&b, "%s\n\n", t) } if d := strings.TrimSpace(req.Description); d != "" { fmt.Fprintf(&b, "%s\n", d) } if len(req.AcceptanceCriteria) > 0 { b.WriteString("\nAcceptance criteria:\n") for _, c := range req.AcceptanceCriteria { fmt.Fprintf(&b, "- %s\n", c) } } if len(req.Links) > 0 { b.WriteString("\nLinks:\n") for _, l := range req.Links { fmt.Fprintf(&b, "- %s\n", l) } } if len(req.Attachments) > 0 { b.WriteString("\nAttachments:\n") for _, a := range req.Attachments { fmt.Fprintf(&b, "- %s (%s): %s\n", a.Name, a.MimeType, a.URL) } } s := strings.TrimSpace(b.String()) if s == "" { s = "(no description provided)" } return s } // guidanceNote carries the consultant's note plus our task-count constraints, // which the API has no dedicated field for. func guidanceNote(req AtomizeRequest) string { parts := make([]string, 0, 2) if n := strings.TrimSpace(req.SubdivisionNote); n != "" { parts = append(parts, n) } if c := req.Constraints; c != nil { switch { case c.MinTasks > 0 && c.MaxTasks > 0: parts = append(parts, fmt.Sprintf("Produce between %d and %d features.", c.MinTasks, c.MaxTasks)) case c.MinTasks > 0: parts = append(parts, fmt.Sprintf("Produce at least %d features.", c.MinTasks)) case c.MaxTasks > 0: parts = append(parts, fmt.Sprintf("Produce at most %d features.", c.MaxTasks)) } } return strings.Join(parts, "\n\n") } // truncate caps a string at max bytes without splitting a rune. Byte length is // a safe proxy for the spec's character limits: it is never smaller. func truncate(s string, max int) string { if len(s) <= max { return s } cut := max for cut > 0 && !utf8.RuneStart(s[cut]) { cut-- } return s[:cut] }