`harness loop` polls the /issues.json intake on an interval (default
120s, --once for cron-style single scans), runs one bounded turn per
new/updated issue — deduped by issue id + updated_on in an append-only
loop.jsonl — then writes the REPORT back as a Redmine journal note,
transitions status per [redmine.status_map] (In Progress -> Done by
name, resolved via /issue_statuses.json), and refreshes the dedup marker
to the post-writeback updated_on so its own notes never re-trigger it.
Turns are sequential (v0); failed turns are recorded, not retried, so a
down proxy cannot hot-loop the poll. The optional Gitea REPORT commit
([gitea] commit_reports, off) rides the same flow. No slot files, no
doorbell screens, no queue scripts — the bash middle layer is replaced,
not wrapped.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
173 lines
4.7 KiB
Go
173 lines
4.7 KiB
Go
// 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] + "..."
|
|
}
|