// Package intake turns released scope into TASK records: Redmine issues in // scope today; local inbox files later. package intake import ( "context" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "ukrrs.com/mopac/harness/internal/config" "ukrrs.com/mopac/harness/internal/task" ) // RedmineClient lists issues in the configured scope via /issues.json. type RedmineClient struct { cfg config.RedmineConfig key string http *http.Client limit int } // NewRedmineClient builds a client from config and a resolved API key. func NewRedmineClient(cfg config.RedmineConfig, key string) *RedmineClient { return &RedmineClient{ cfg: cfg, key: key, http: &http.Client{Timeout: 30 * time.Second}, limit: cfg.Limit, } } // ListTasks returns the issues in scope, in the order Redmine returns them. // The task class comes from the configured custom field, falling back to the // configured default class. func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) { q := c.cfg.ScopeQuery if q == "" && c.cfg.ScopeQueryID > 0 { q = "query_id=" + strconv.Itoa(c.cfg.ScopeQueryID) } if !strings.Contains(q, "limit=") && c.limit > 0 { q += "&limit=" + strconv.Itoa(c.limit) } url := strings.TrimSuffix(c.cfg.URL, "/") + "/issues.json?" + q httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } httpReq.Header.Set("X-Redmine-API-Key", c.key) resp, err := c.http.Do(httpReq) if err != nil { return nil, fmt.Errorf("redmine request: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return nil, fmt.Errorf("redmine read: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("redmine HTTP %d: %s", resp.StatusCode, truncate(string(raw), 300)) } var payload struct { Issues []struct { ID int `json:"id"` Subject string `json:"subject"` Description string `json:"description"` CustomFields []struct { Name string `json:"name"` Value string `json:"value"` } `json:"custom_fields"` } `json:"issues"` } if err := json.Unmarshal(raw, &payload); err != nil { return nil, fmt.Errorf("redmine decode: %w", err) } tasks := make([]task.Task, 0, len(payload.Issues)) for _, is := range payload.Issues { class := c.cfg.DefaultClass for _, cf := range is.CustomFields { if strings.EqualFold(cf.Name, c.cfg.ClassField) && cf.Value != "" { class = cf.Value } } prompt := is.Description if prompt == "" { prompt = is.Subject } tasks = append(tasks, task.Task{ ID: strconv.Itoa(is.ID), Subject: is.Subject, Prompt: prompt, Class: class, Source: "redmine", }) } return tasks, nil } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }