Compare commits
1 Commits
39ec958f04
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c9c39676 |
@@ -0,0 +1,13 @@
|
||||
# Container-owned data: unreadable by the build user, and stat'ing it fails the
|
||||
# context walk outright.
|
||||
.volumes/
|
||||
backups/
|
||||
|
||||
.git/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local working backups.
|
||||
.bak-*/
|
||||
**/.bak-*/
|
||||
@@ -24,6 +24,12 @@ ADMIN_INITIAL_PASSWORD=change-me-now
|
||||
ATOMIZER_BASE_URL=http://atomizer-mock:8090
|
||||
ATOMIZER_TOKEN=change-me-atomizer
|
||||
ATOMIZER_TIMEOUT_SEC=120
|
||||
# Optional: serve Subdivide from the Anypreta Issue Atomizer instead of the
|
||||
# §5.1 service above. Both vars are required to enable it. Extend and Summarize
|
||||
# have no counterpart in that API and always stay on ATOMIZER_BASE_URL, so the
|
||||
# service above must keep running either way.
|
||||
ATOMIZER_REMOTE_BASE_URL=
|
||||
ATOMIZER_REMOTE_API_KEY=
|
||||
WORK_PERFORMER_BASE_URL=http://work-performer:8091
|
||||
WORK_PERFORMER_TOKEN=change-me-performer
|
||||
# Job submission/polling only; jobs themselves are async.
|
||||
|
||||
@@ -98,6 +98,13 @@ func run() error {
|
||||
}
|
||||
return cfg.AtomizerBaseURL
|
||||
}, cfg.AtomizerToken, cfg.AtomizerTimeout)
|
||||
if cfg.AtomizerRemoteBaseURL != "" {
|
||||
// Subdivide is served by the Anypreta Issue Atomizer; Extend and
|
||||
// Summarize have no counterpart there and stay on ATOMIZER_BASE_URL.
|
||||
atomClient.UseRemote(cfg.AtomizerRemoteBaseURL, cfg.AtomizerRemoteAPIKey, cfg.AtomizerTimeout)
|
||||
log.Info("atomizer: subdivide served by remote backend",
|
||||
"url", cfg.AtomizerRemoteBaseURL, "extend_summarize", cfg.AtomizerBaseURL)
|
||||
}
|
||||
srv.SetAtomizerInfo(atomClient)
|
||||
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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 <key>.
|
||||
|
||||
// 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]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package atomize
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptr(f float64) *float64 { return &f }
|
||||
|
||||
func sum(xs []float64) float64 {
|
||||
t := 0.0
|
||||
for _, x := range xs {
|
||||
t += x
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsUsesEstimations(t *testing.T) {
|
||||
// The shape the live API returns: estimations already summing to 1.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.3636363636)},
|
||||
{Estimation: ptr(0.6363636364)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !estimated {
|
||||
t.Fatal("expected estimations to be used")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("coefficients must sum to exactly 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
if math.Abs(got[0]-0.3636) > 1e-9 {
|
||||
t.Fatalf("estimation not preserved: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsRescalesUnnormalized(t *testing.T) {
|
||||
// Nothing in the spec promises a normalized feature set.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.2)}, {Estimation: ptr(0.2)}, {Estimation: ptr(0.4)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !estimated {
|
||||
t.Fatal("expected estimations to be used")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
if math.Abs(got[2]-0.5) > 1e-9 {
|
||||
t.Fatalf("want proportional rescale (0.25/0.25/0.5), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsFallsBackToEvenSplit(t *testing.T) {
|
||||
// estimation is optional and nullable — a missing one must not fail the
|
||||
// job, and must be reported so the consultant knows the split is not real.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.5)}, {Estimation: nil}, {Estimation: ptr(0.2)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if estimated {
|
||||
t.Fatal("expected fallback to even split when an estimation is missing")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
for _, c := range got {
|
||||
if math.Abs(c-1.0/3.0) > 1e-3 {
|
||||
t.Fatalf("want even split, got %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeToOneRejectsNonPositive(t *testing.T) {
|
||||
for _, bad := range [][]float64{{0.5, 0}, {0.5, -0.1}, {math.NaN()}, {}} {
|
||||
if _, err := normalizeToOne(bad); err == nil {
|
||||
t.Fatalf("want error for %v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTextCarriesFieldsTheAPILacks(t *testing.T) {
|
||||
// The API takes a single `issue` string: criteria, links and attachments
|
||||
// have no fields of their own and must survive in the text.
|
||||
got := issueText(AtomizeRequest{
|
||||
Title: "Add CSV export",
|
||||
Description: "Customers cannot export.",
|
||||
AcceptanceCriteria: []string{"Downloads a .csv"},
|
||||
Links: []string{"https://tracker/1"},
|
||||
Attachments: []Attachment{{Name: "mock.png", MimeType: "image/png", URL: "https://app/f/1"}},
|
||||
})
|
||||
for _, want := range []string{"Add CSV export", "Customers cannot export.", "Downloads a .csv", "https://tracker/1", "mock.png", "https://app/f/1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("issue text lost %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuidanceNoteFoldsInConstraints(t *testing.T) {
|
||||
got := guidanceNote(AtomizeRequest{
|
||||
SubdivisionNote: "Keep it small.",
|
||||
Constraints: &Constraints{MinTasks: 2, MaxTasks: 5},
|
||||
})
|
||||
if !strings.Contains(got, "Keep it small.") || !strings.Contains(got, "between 2 and 5") {
|
||||
t.Fatalf("want note + constraints, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDoesNotSplitRunes(t *testing.T) {
|
||||
if got := truncate("héllo", 2); got != "h" {
|
||||
t.Fatalf("want %q, got %q", "h", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Exercises the full remote path against a stub speaking the Anypreta wire
|
||||
// format, asserting both the request we send and the children we build.
|
||||
func TestRemoteAtomizeMapsFeaturesToChildren(t *testing.T) {
|
||||
var got apAtomizeIssueRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/atomize-issue" {
|
||||
t.Errorf("wrong path: %s", r.URL.Path)
|
||||
}
|
||||
if auth := r.Header.Get("Authorization"); auth != "Bearer k" {
|
||||
t.Errorf("wrong auth header: %q", auth)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"feature_set":{"features":[
|
||||
{"id":"f1","title":"UI","stakeholder_summary":"s","description":"d1",
|
||||
"acceptance_criteria":[{"description":"c1"},{"description":"c2"}],
|
||||
"priority":1,"related_components":["web"],"estimation":0.25},
|
||||
{"id":"f2","title":"API","stakeholder_summary":"s","description":"d2",
|
||||
"acceptance_criteria":[{"description":"c3"}],
|
||||
"priority":2,"related_components":["api"],"estimation":0.75}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := newRemote(srv.URL, "k", 5*time.Second).Atomize(t.Context(), AtomizeRequest{
|
||||
TaskID: "t1", Title: "Export", Description: "desc",
|
||||
CustomerID: "cust1", CustomerName: "Acme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Atomize: %v", err)
|
||||
}
|
||||
if got.System.ID != "cust1" || !strings.Contains(got.System.Description, "Acme") {
|
||||
t.Errorf("system not derived from customer: %+v", got.System)
|
||||
}
|
||||
// An empty component tree makes the live service return zero features
|
||||
// (422), so never let the tree go missing.
|
||||
if len(got.System.Components) == 0 {
|
||||
t.Error("system.components must not be empty: the service only emits features for in-scope components")
|
||||
}
|
||||
if len(resp.Tasks) != 2 {
|
||||
t.Fatalf("want 2 children, got %d", len(resp.Tasks))
|
||||
}
|
||||
if resp.Tasks[0].Title != "UI" || resp.Tasks[0].Description != "d1" {
|
||||
t.Errorf("bad child mapping: %+v", resp.Tasks[0])
|
||||
}
|
||||
if len(resp.Tasks[0].AcceptanceCriteria) != 2 || resp.Tasks[0].AcceptanceCriteria[0] != "c1" {
|
||||
t.Errorf("acceptance criteria not mapped: %+v", resp.Tasks[0].AcceptanceCriteria)
|
||||
}
|
||||
if resp.Tasks[0].EffortCoefficient != 0.25 || resp.Tasks[1].EffortCoefficient != 0.75 {
|
||||
t.Errorf("estimation not mapped to effort coefficient: %v / %v",
|
||||
resp.Tasks[0].EffortCoefficient, resp.Tasks[1].EffortCoefficient)
|
||||
}
|
||||
if resp.Notes != "" {
|
||||
t.Errorf("no even-split note expected when estimations are present: %q", resp.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
// A remote-backed client must still serve Extend and Summarize from the §5.1
|
||||
// service — the Anypreta API has no counterpart for either.
|
||||
func TestRemoteDoesNotHijackExtend(t *testing.T) {
|
||||
var hit string
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"task":{"title":"t","description":"d","acceptanceCriteria":[],"effortCoefficient":0.5}}`))
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
c := New(func() string { return mock.URL }, "tok", 5*time.Second)
|
||||
c.UseRemote("http://remote.invalid", "k", 5*time.Second)
|
||||
|
||||
if _, err := c.Extend(t.Context(), ExtendRequest{TaskID: "t1", ExtensionNote: "more"}); err != nil {
|
||||
t.Fatalf("Extend: %v", err)
|
||||
}
|
||||
if hit != "/v1/extend" {
|
||||
t.Fatalf("Extend must go to the §5.1 service, hit %q", hit)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ type AtomizeRequest struct {
|
||||
Links []string `json:"links"`
|
||||
SubdivisionNote string `json:"subdivisionNote,omitempty"`
|
||||
Constraints *Constraints `json:"constraints,omitempty"`
|
||||
// CustomerID / CustomerName describe the project the ticket belongs to.
|
||||
// The §5.1 service ignores them; the Anypreta backend requires them to
|
||||
// build its mandatory `system` object.
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerName string `json:"customerName,omitempty"`
|
||||
}
|
||||
|
||||
type ExtendRequest struct {
|
||||
@@ -70,6 +75,10 @@ var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficien
|
||||
|
||||
type Client struct {
|
||||
c *extsvc.Client
|
||||
// remote, when set, serves Atomize from the Anypreta Issue Atomizer
|
||||
// instead of the §5.1 service. Extend and Summarize have no counterpart
|
||||
// there and always go to c. See anypreta.go.
|
||||
remote *remote
|
||||
}
|
||||
|
||||
// New builds the client. baseURL is resolved per call so the admin settings
|
||||
@@ -78,13 +87,46 @@ func New(baseURL func() string, token string, timeout time.Duration) *Client {
|
||||
return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)}
|
||||
}
|
||||
|
||||
func (a *Client) BreakerState() string { return a.c.BreakerState() }
|
||||
func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) }
|
||||
// UseRemote routes Atomize to the Anypreta Issue Atomizer at baseURL.
|
||||
func (a *Client) UseRemote(baseURL, apiKey string, timeout time.Duration) {
|
||||
a.remote = newRemote(baseURL, apiKey, timeout)
|
||||
}
|
||||
|
||||
// RemoteEnabled reports whether Subdivide is served by the Anypreta backend.
|
||||
func (a *Client) RemoteEnabled() bool { return a.remote != nil }
|
||||
|
||||
func (a *Client) BreakerState() string {
|
||||
if a.remote != nil {
|
||||
if s := a.remote.c.BreakerState(); s != "closed" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return a.c.BreakerState()
|
||||
}
|
||||
|
||||
// Healthy probes every backend an atomization can touch, so readiness and the
|
||||
// admin service panel go degraded if either half is down.
|
||||
func (a *Client) Healthy(ctx context.Context) error {
|
||||
if err := a.c.Healthy(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.remote != nil {
|
||||
if err := a.remote.c.Healthy(ctx); err != nil {
|
||||
return fmt.Errorf("anypreta atomizer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients
|
||||
// must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized,
|
||||
// anything worse is rejected as a service error.
|
||||
// anything worse is rejected as a service error. With a remote backend
|
||||
// configured the call is served by the Anypreta API instead, which normalizes
|
||||
// its own feature estimations.
|
||||
func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
|
||||
if a.remote != nil {
|
||||
return a.remote.Atomize(ctx, req)
|
||||
}
|
||||
var resp AtomizeResponse
|
||||
if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -99,6 +99,15 @@ func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error {
|
||||
Attachments: s.requestAttachments(t),
|
||||
Links: taskLinks(t),
|
||||
SubdivisionNote: p.Note,
|
||||
CustomerID: t.CustomerID,
|
||||
}
|
||||
// Project context for backends that need it (the Anypreta `system`
|
||||
// object). Not worth failing an atomization over.
|
||||
if cust, err := s.st.CustomerByID(ctx, t.CustomerID); err == nil {
|
||||
req.CustomerName = cust.Name
|
||||
} else {
|
||||
s.log.Warn("subdivide: customer lookup failed, sending id only",
|
||||
"task", t.ID, "customer", t.CustomerID, "err", err)
|
||||
}
|
||||
if p.MinTasks > 0 || p.MaxTasks > 0 {
|
||||
req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks}
|
||||
|
||||
@@ -54,9 +54,14 @@ type Config struct {
|
||||
AdminEmail string
|
||||
AdminInitialPassword string
|
||||
|
||||
AtomizerBaseURL string
|
||||
AtomizerToken string
|
||||
AtomizerTimeout time.Duration
|
||||
AtomizerBaseURL string
|
||||
AtomizerToken string
|
||||
AtomizerTimeout time.Duration
|
||||
// AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue
|
||||
// Atomizer as the Subdivide backend. Both must be set; Extend and
|
||||
// Summarize stay on ATOMIZER_BASE_URL either way.
|
||||
AtomizerRemoteBaseURL string
|
||||
AtomizerRemoteAPIKey string
|
||||
WorkPerformerBaseURL string
|
||||
WorkPerformerToken string
|
||||
WorkPerformerHTTPTimeout time.Duration
|
||||
@@ -115,10 +120,12 @@ func Load() (*Config, error) {
|
||||
AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))),
|
||||
AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"),
|
||||
|
||||
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
|
||||
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
|
||||
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
|
||||
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
|
||||
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
|
||||
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
|
||||
AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"),
|
||||
AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"),
|
||||
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
|
||||
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
|
||||
|
||||
SMTP: SMTP{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
@@ -155,6 +162,14 @@ func Load() (*Config, error) {
|
||||
fail("%s: %q is not an absolute URL", name, v)
|
||||
}
|
||||
}
|
||||
if c.AtomizerRemoteBaseURL != "" {
|
||||
if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL)
|
||||
}
|
||||
if c.AtomizerRemoteAPIKey == "" {
|
||||
fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set")
|
||||
}
|
||||
}
|
||||
if len(c.SessionSecret) < 16 {
|
||||
fail("SESSION_SECRET: must be at least 16 characters of random data")
|
||||
}
|
||||
|
||||
@@ -121,13 +121,34 @@ func (c *Client) callOnce(ctx context.Context, method, path string, in, out any)
|
||||
if resp.StatusCode >= 400 {
|
||||
se := &ServiceError{Status: resp.StatusCode}
|
||||
var env struct {
|
||||
// §5 envelope: {"error":{"code":…,"message":…}}.
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
// Flat envelope, used by the Anypreta atomizer:
|
||||
// {"error_code":…,"message":…}. FastAPI's own validation
|
||||
// errors carry {"detail":…} instead.
|
||||
ErrorCode string `json:"error_code"`
|
||||
Message string `json:"message"`
|
||||
Detail json.RawMessage `json:"detail"`
|
||||
}
|
||||
if json.Unmarshal(data, &env) == nil {
|
||||
se.Code, se.Message = env.Error.Code, env.Error.Message
|
||||
if se.Code == "" {
|
||||
se.Code = env.ErrorCode
|
||||
}
|
||||
if se.Message == "" {
|
||||
se.Message = env.Message
|
||||
}
|
||||
if se.Message == "" && len(env.Detail) > 0 {
|
||||
se.Message = string(truncate(env.Detail, 200))
|
||||
}
|
||||
}
|
||||
if se.Message == "" {
|
||||
// Never swallow the failure: an unrecognized envelope still
|
||||
// has to reach the logs and the consultant's error toast.
|
||||
se.Message = string(truncate(data, 200))
|
||||
}
|
||||
return se // 4xx is not retryable
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user