// Redmine writeback: after a turn, the loop notes the REPORT back on the // issue (journal note via PUT /issues/{id}.json) and moves the issue status // per the configured [redmine.status_map] (names resolved to ids via // /issue_statuses.json). Intake stays read-only; all writes live here. package writeback import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "ukrrs.com/mopac/harness/internal/config" ) // RedmineWriter is the write side of the SoR: notes + status transitions. type RedmineWriter struct { url string key string http *http.Client mu sync.Mutex statusIDs map[string]int // status name -> id, cached per process } // NewRedmineWriter builds the write client from config and a resolved key. func NewRedmineWriter(cfg config.RedmineConfig, key string) *RedmineWriter { return &RedmineWriter{ url: strings.TrimSuffix(cfg.URL, "/"), key: key, http: &http.Client{Timeout: 30 * time.Second}, statusIDs: nil, // fetched lazily on first status transition } } // AddNote appends a journal note to the issue (the REPORT lands in the SoR). func (w *RedmineWriter) AddNote(ctx context.Context, issueID, note string) error { var body struct { Issue struct { Notes string `json:"notes"` } `json:"issue"` } body.Issue.Notes = note return w.putIssue(ctx, issueID, body) } // SetStatus moves the issue to the named status. The name is resolved to an // id via /issue_statuses.json (fetched once, cached); unknown names error. func (w *RedmineWriter) SetStatus(ctx context.Context, issueID, statusName string) error { id, err := w.statusID(ctx, statusName) if err != nil { return err } var body struct { Issue struct { StatusID int `json:"status_id"` } `json:"issue"` } body.Issue.StatusID = id return w.putIssue(ctx, issueID, body) } // UpdatedOn fetches the issue's current updated_on. The loop calls it right // after its own writebacks (which bump updated_on) so the dedup marker // reflects the post-writeback state instead of re-triggering itself. func (w *RedmineWriter) UpdatedOn(ctx context.Context, issueID string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.url+"/issues/"+issueID+".json", nil) if err != nil { return "", err } raw, err := w.do(req) if err != nil { return "", err } var payload struct { Issue struct { UpdatedOn string `json:"updated_on"` } `json:"issue"` } if err := json.Unmarshal(raw, &payload); err != nil { return "", fmt.Errorf("redmine decode: %w", err) } return payload.Issue.UpdatedOn, nil } func (w *RedmineWriter) statusID(ctx context.Context, name string) (int, error) { w.mu.Lock() if w.statusIDs != nil { id, ok := w.statusIDs[strings.ToLower(name)] w.mu.Unlock() if !ok { return 0, fmt.Errorf("redmine status %q not found in /issue_statuses.json", name) } return id, nil } w.mu.Unlock() req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.url+"/issue_statuses.json", nil) if err != nil { return 0, err } raw, err := w.do(req) if err != nil { return 0, err } var payload struct { Statuses []struct { ID int `json:"id"` Name string `json:"name"` } `json:"issue_statuses"` } if err := json.Unmarshal(raw, &payload); err != nil { return 0, fmt.Errorf("redmine decode statuses: %w", err) } ids := make(map[string]int, len(payload.Statuses)) for _, st := range payload.Statuses { ids[strings.ToLower(st.Name)] = st.ID } w.mu.Lock() w.statusIDs = ids w.mu.Unlock() id, ok := ids[strings.ToLower(name)] if !ok { return 0, fmt.Errorf("redmine status %q not found in /issue_statuses.json", name) } return id, nil } func (w *RedmineWriter) putIssue(ctx context.Context, issueID string, body any) error { raw, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPut, w.url+"/issues/"+issueID+".json", bytes.NewReader(raw)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") if _, err := w.do(req); err != nil { return err } return nil } func (w *RedmineWriter) do(req *http.Request) ([]byte, error) { req.Header.Set("X-Redmine-API-Key", w.key) resp, err := w.http.Do(req) if err != nil { return nil, fmt.Errorf("redmine request: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<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, truncateFor(string(raw), 300)) } return raw, nil } func truncateFor(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }