harness loop: self-hosting daemon — Redmine SoR drives its own turns
`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
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// Gitea REPORT commit (optional, [gitea] commit_reports = true): right
|
||||
// after a REPORT file lands, the loop commits it to the configured repo via
|
||||
// the contents API (create, or update with the existing blob sha). All
|
||||
// endpoints come from config; nothing host-specific lives in code.
|
||||
package writeback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ukrrs.com/mopac/harness/internal/config"
|
||||
)
|
||||
|
||||
// GiteaWriter commits files to one configured repo.
|
||||
type GiteaWriter struct {
|
||||
url string
|
||||
key string
|
||||
owner string
|
||||
repo string
|
||||
branch string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewGiteaWriter builds the client from config and a resolved key.
|
||||
func NewGiteaWriter(cfg config.GiteaConfig, key string) *GiteaWriter {
|
||||
return &GiteaWriter{
|
||||
url: strings.TrimSuffix(cfg.URL, "/"),
|
||||
key: key,
|
||||
owner: cfg.Owner,
|
||||
repo: cfg.Repo,
|
||||
branch: cfg.Branch,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// CommitFile creates or updates path in the repo with content. If the file
|
||||
// already exists its blob sha is fetched and the update carried out.
|
||||
func (w *GiteaWriter) CommitFile(ctx context.Context, path, content, message string) error {
|
||||
api := w.url + "/api/v1/repos/" + w.owner + "/" + w.repo + "/contents/" + path
|
||||
|
||||
var sha string
|
||||
body, err := w.do(ctx, http.MethodGet, api+"?ref="+url.QueryEscape(w.branch), nil)
|
||||
if err == nil {
|
||||
var cur struct {
|
||||
SHA string `json:"sha"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(body, &cur) == nil && cur.Type == "file" {
|
||||
sha = cur.SHA
|
||||
}
|
||||
} else if !isNotFound(err) {
|
||||
return err // GET failed for a reason other than "does not exist"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if w.branch != "" {
|
||||
payload["branch"] = w.branch
|
||||
}
|
||||
method := http.MethodPost
|
||||
if sha != "" {
|
||||
payload["sha"] = sha
|
||||
method = http.MethodPut
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.do(ctx, method, api, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *GiteaWriter) do(ctx context.Context, method, api string, body []byte) ([]byte, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, api, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+w.key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := w.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea read: %w", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, errNotFound
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("gitea HTTP %d: %s", resp.StatusCode, truncateFor(string(raw), 300))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
type notFoundError struct{}
|
||||
|
||||
func (notFoundError) Error() string { return "gitea: not found" }
|
||||
|
||||
var errNotFound error = notFoundError{}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
_, ok := err.(notFoundError)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package writeback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"ukrrs.com/mopac/harness/internal/config"
|
||||
)
|
||||
|
||||
func TestRedmineWriterNoteAndStatus(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var puts []struct {
|
||||
path string
|
||||
body string
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Redmine-API-Key"); got != "rm-key" {
|
||||
http.Error(w, "bad key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/issues/421.json":
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
puts = append(puts, struct{ path, body string }{r.URL.Path, string(raw)})
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/issue_statuses.json":
|
||||
w.Write([]byte(`{"issue_statuses":[{"id":2,"name":"In Progress"},{"id":3,"name":"Done"}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/issues/421.json":
|
||||
w.Write([]byte(`{"issue":{"id":421,"updated_on":"2026-08-28T22:31:11Z"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
w := NewRedmineWriter(config.RedmineConfig{URL: srv.URL}, "rm-key")
|
||||
if err := w.AddNote(context.Background(), "421", "REPORT body here"); err != nil {
|
||||
t.Fatalf("AddNote: %v", err)
|
||||
}
|
||||
if err := w.SetStatus(context.Background(), "421", "Done"); err != nil {
|
||||
t.Fatalf("SetStatus: %v", err)
|
||||
}
|
||||
updated, err := w.UpdatedOn(context.Background(), "421")
|
||||
if err != nil || updated != "2026-08-28T22:31:11Z" {
|
||||
t.Fatalf("UpdatedOn = %q err=%v", updated, err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(puts) != 2 {
|
||||
t.Fatalf("PUTs = %d, want 2 (note + status)", len(puts))
|
||||
}
|
||||
var noteBody struct {
|
||||
Issue struct {
|
||||
Notes string `json:"notes"`
|
||||
} `json:"issue"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(puts[0].body), ¬eBody); err != nil {
|
||||
t.Fatalf("note PUT body: %v", err)
|
||||
}
|
||||
if noteBody.Issue.Notes != "REPORT body here" {
|
||||
t.Errorf("note body = %+v", noteBody)
|
||||
}
|
||||
var statusBody struct {
|
||||
Issue struct {
|
||||
StatusID int `json:"status_id"`
|
||||
} `json:"issue"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(puts[1].body), &statusBody); err != nil {
|
||||
t.Fatalf("status PUT body: %v", err)
|
||||
}
|
||||
if statusBody.Issue.StatusID != 3 {
|
||||
t.Errorf("status_id = %d, want 3 (Done)", statusBody.Issue.StatusID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedmineWriterUnknownStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/issue_statuses.json" {
|
||||
w.Write([]byte(`{"issue_statuses":[{"id":3,"name":"Done"}]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := NewRedmineWriter(config.RedmineConfig{URL: srv.URL}, "k")
|
||||
err := w.SetStatus(context.Background(), "421", "Nonexistent")
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("err = %v, want unknown-status error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaWriterCreateAndUpdate(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
sha := ""
|
||||
var methods []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "token gt-key" {
|
||||
http.Error(w, "bad token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/repos/ukrrs/reports/contents/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if sha == "" {
|
||||
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"sha":"` + sha + `","type":"file"}`))
|
||||
case http.MethodPost, http.MethodPut:
|
||||
methods = append(methods, r.Method)
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
json.Unmarshal(raw, &body)
|
||||
if body["branch"] != "main" {
|
||||
t.Errorf("branch = %v, want main", body["branch"])
|
||||
}
|
||||
if r.Method == http.MethodPut && body["sha"] == nil {
|
||||
t.Errorf("update PUT must carry the existing sha")
|
||||
}
|
||||
sha = "newsha"
|
||||
w.Write([]byte(`{"content":{"path":"reports/x.md"}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
w := NewGiteaWriter(config.GiteaConfig{
|
||||
URL: srv.URL, Owner: "ukrrs", Repo: "reports", Branch: "main",
|
||||
}, "gt-key")
|
||||
|
||||
if err := w.CommitFile(context.Background(), "reports/x.md", "hello", "first"); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := w.CommitFile(context.Background(), "reports/x.md", "hello again", "second"); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(methods) != 2 || methods[0] != http.MethodPost || methods[1] != http.MethodPut {
|
||||
t.Errorf("methods = %v, want [POST PUT] (create then update)", methods)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user