From ecc0ee874b488b1139c0fda3299657055c5ed397 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 28 Aug 2026 22:17:03 -0500 Subject: [PATCH] =?UTF-8?q?harness=20loop:=20self-hosting=20daemon=20?= =?UTF-8?q?=E2=80=94=20Redmine=20SoR=20drives=20its=20own=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- cmd/harness/main.go | 72 ++++- internal/intake/redmine.go | 16 +- internal/loop/daemon.go | 250 +++++++++++++++++ internal/loop/daemon_test.go | 415 +++++++++++++++++++++++++++++ internal/loop/loop.go | 24 +- internal/loop/state.go | 144 ++++++++++ internal/task/task.go | 8 + internal/writeback/gitea.go | 125 +++++++++ internal/writeback/redmine.go | 172 ++++++++++++ internal/writeback/redmine_test.go | 155 +++++++++++ 10 files changed, 1359 insertions(+), 22 deletions(-) create mode 100644 internal/loop/daemon.go create mode 100644 internal/loop/daemon_test.go create mode 100644 internal/loop/state.go create mode 100644 internal/writeback/gitea.go create mode 100644 internal/writeback/redmine.go create mode 100644 internal/writeback/redmine_test.go diff --git a/cmd/harness/main.go b/cmd/harness/main.go index e1c9e21..cfcef86 100644 --- a/cmd/harness/main.go +++ b/cmd/harness/main.go @@ -1,5 +1,5 @@ // Command harness is the MOPAC harness CLI. v0 surface: `harness once`, -// `harness events`. +// `harness loop`, `harness events`. package main import ( @@ -22,26 +22,37 @@ const usage = `MOPAC harness (v0) Usage: harness once [-config PATH] [-dry-run] [-demo] [-task-id ID] + harness loop [-config PATH] [-interval DUR] [-once] [-dry-run] harness events [-config PATH] [-listen ADDR] once runs ONE conductor iteration and exits (chain by re-invoking; no daemon): intake (Redmine scope, or the [demo] issue) -> plan/model routing -> bounded turn via LiteLLM -> REPORT file writeback. +loop is the self-hosting daemon: Redmine is the SoR, the loop is the worker. + It polls the intake on an interval, runs ONE bounded turn per new/updated + issue (sequentially), notes the REPORT back on the issue, transitions + status per [redmine.status_map], optionally commits the REPORT to gitea + ([gitea] commit_reports). State is append-only loop.jsonl, dedup by + issue id + updated_on. --once = single scan (cron-able). SIGINT stops. + events runs the webhook receiver until SIGINT/SIGTERM: Redmine/Discourse/ Gitea webhooks are verified, normalized, stored append-only (dedup by provider event id) and handed to the conductor (dispatch stub for now). Routes: POST /hooks/{redmine,discourse,gitea}, GET /healthz. Flags: - -config PATH config file (default $HARNESS_CONFIG or ./harness.toml) - -dry-run intake + plan only; no LLM call, no REPORT - -demo run the [demo] issue instead of Redmine intake - -task-id ID run only the task/issue with this id - -listen ADDR events: bind address (overrides [events] listen) + -config PATH config file (default $HARNESS_CONFIG or ./harness.toml) + -dry-run once/loop: intake + plan only; no LLM call, no REPORT, + no state writes + -demo once: run the [demo] issue instead of Redmine intake + -task-id ID once: run only the task/issue with this id + -interval DUR loop: poll interval (overrides [loop] poll_interval_secs) + -once loop: single scan then exit (cron-able) + -listen ADDR events: bind address (overrides [events] listen) Exit codes: - 0 ok (including "no tasks in scope") + 0 ok (including "no tasks in scope"; loop: clean SIGINT stop) 1 usage / config / routing / writeback error 2 intake error 4 llm / turn error @@ -62,6 +73,8 @@ func run(args []string) int { return 0 case "once": return runOnce(args[1:]) + case "loop": + return runLoop(args[1:]) case "events": return runEvents(args[1:]) default: @@ -123,6 +136,51 @@ func runOnce(args []string) int { return 0 } +func runLoop(args []string) int { + fs := flag.NewFlagSet("loop", flag.ContinueOnError) + cfgPath := fs.String("config", "", "config file path") + interval := fs.Duration("interval", 0, "poll interval (overrides [loop] poll_interval_secs)") + once := fs.Bool("once", false, "single scan then exit (cron-able)") + dryRun := fs.Bool("dry-run", false, "scan and print what would dispatch; no turns, no state writes") + if err := fs.Parse(args); err != nil { + return 1 + } + if fs.NArg() > 0 { + fmt.Fprintf(os.Stderr, "harness: unexpected argument %q\n", fs.Arg(0)) + return 1 + } + + if *cfgPath == "" { + *cfgPath = os.Getenv("HARNESS_CONFIG") + } + if *cfgPath == "" { + *cfgPath = "harness.toml" + } + cfg, err := config.Load(*cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "harness: %v\n", err) + return 1 + } + conductor, err := loop.New(cfg, os.Stdout) + if err != nil { + fmt.Fprintf(os.Stderr, "harness: %v\n", err) + return 1 + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := conductor.RunLoop(ctx, loop.LoopOpts{ + Interval: *interval, + Once: *once, + DryRun: *dryRun, + }); err != nil { + fmt.Fprintf(os.Stderr, "harness: %v\n", err) + return 1 + } + return 0 +} + func runEvents(args []string) int { fs := flag.NewFlagSet("events", flag.ContinueOnError) cfgPath := fs.String("config", "", "config file path") diff --git a/internal/intake/redmine.go b/internal/intake/redmine.go index 1b765ce..12e2214 100644 --- a/internal/intake/redmine.go +++ b/internal/intake/redmine.go @@ -70,6 +70,10 @@ func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) { ID int `json:"id"` Subject string `json:"subject"` Description string `json:"description"` + UpdatedOn string `json:"updated_on"` + Status struct { + Name string `json:"name"` + } `json:"status"` CustomFields []struct { Name string `json:"name"` Value string `json:"value"` @@ -93,11 +97,13 @@ func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) { prompt = is.Subject } tasks = append(tasks, task.Task{ - ID: strconv.Itoa(is.ID), - Subject: is.Subject, - Prompt: prompt, - Class: class, - Source: "redmine", + ID: strconv.Itoa(is.ID), + Subject: is.Subject, + Prompt: prompt, + Class: class, + Source: "redmine", + UpdatedOn: is.UpdatedOn, + Status: is.Status.Name, }) } return tasks, nil diff --git a/internal/loop/daemon.go b/internal/loop/daemon.go new file mode 100644 index 0000000..8402ae6 --- /dev/null +++ b/internal/loop/daemon.go @@ -0,0 +1,250 @@ +// Self-host daemon: `harness loop` polls the Redmine intake on an interval, +// runs ONE bounded turn per new/updated issue (sequentially, v0), notes the +// REPORT back on the issue, transitions status per the config map, and +// optionally commits the REPORT to gitea. Redmine is the SoR; this loop is +// the worker. It replaces the crossfeed bash stack: no slot files, no +// doorbell screens, no queue scripts. +package loop + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "ukrrs.com/mopac/harness/internal/models" + "ukrrs.com/mopac/harness/internal/task" + "ukrrs.com/mopac/harness/internal/writeback" +) + +// LoopOpts controls the daemon. +type LoopOpts struct { + Interval time.Duration // poll interval; 0 = [loop] poll_interval_secs + Once bool // single scan then return (cron-able) + DryRun bool // scan + print what would dispatch; no turns, no state writes +} + +// RunLoop is the daemon body. It returns nil on context cancel (SIGINT), +// and a non-nil error only for startup/config failures. +func (c *Conductor) RunLoop(ctx context.Context, opts LoopOpts) error { + rc := c.cfg.Redmine + if rc.URL == "" { + return fmt.Errorf("loop: [redmine] url is required (the loop is Redmine-driven; use `harness once --demo` for the demo issue)") + } + + interval := opts.Interval + if interval <= 0 { + interval = time.Duration(c.cfg.Loop.PollIntervalSecs) * time.Second + } + + var state *loopState + var err error + if opts.DryRun { + // Dry-run: dedup awareness without leaving any trace on disk. + state, err = openLoopStateReadOnly(c.cfg.Loop.StateDir) + } else { + state, err = openLoopState(c.cfg.Loop.StateDir) + } + if err != nil { + return err + } + defer state.Close() + + key, err := c.keys.Resolve(ctx, rc.KeyRef) + if err != nil { + return fmt.Errorf("loop: redmine key: %w", err) + } + writer := writeback.NewRedmineWriter(rc, key) + + var gitea *writeback.GiteaWriter + if c.cfg.Gitea.CommitReports { + gkey, err := c.keys.Resolve(ctx, c.cfg.Gitea.KeyRef) + if err != nil { + return fmt.Errorf("loop: gitea key: %w", err) + } + gitea = writeback.NewGiteaWriter(c.cfg.Gitea, gkey) + } + + fmt.Fprintf(c.out, "harness: loop: vertical=%s poll=%s state=%s redmine=%s (SIGINT to stop)\n", + c.cfg.Vertical, interval, c.cfg.Loop.StateDir, c.redmineHost()) + if gitea != nil { + fmt.Fprintf(c.out, "harness: loop: gitea report commit on (%s/%s)\n", c.cfg.Gitea.Owner, c.cfg.Gitea.Repo) + } + + for { + if err := c.scanOnce(ctx, state, writer, gitea, opts.DryRun); err != nil { + // Scan failures (redmine down, decode hiccups) are logged and + // retried on the next tick; only ctx cancel ends the daemon. + fmt.Fprintf(c.out, "harness: loop: scan error: %v (retrying next interval)\n", err) + _ = state.log(loopEvent{Type: evError, Detail: "scan: " + err.Error()}) + } + if opts.Once { + return nil + } + select { + case <-ctx.Done(): + fmt.Fprintf(c.out, "harness: loop: stopped\n") + return nil + case <-time.After(interval): + } + } +} + +// scanOnce runs one intake scan: every task whose (id, updated_on) has not +// been processed gets one bounded turn + writebacks, strictly sequentially. +func (c *Conductor) scanOnce(ctx context.Context, state *loopState, writer *writeback.RedmineWriter, gitea *writeback.GiteaWriter, dryRun bool) error { + tasks, err := c.intake(ctx, OnceOpts{}) + if err != nil { + return err + } + fresh := 0 + for _, t := range tasks { + if !state.processed(t.ID, t.UpdatedOn) { + fresh++ + } + } + fmt.Fprintf(c.out, "harness: loop: scan: %d task(s) in scope, %d new/updated\n", len(tasks), fresh) + + for _, t := range tasks { + if state.processed(t.ID, t.UpdatedOn) { + continue + } + if dryRun { + fmt.Fprintf(c.out, "harness: loop: would dispatch %s (updated %s): %q\n", t.ID, t.UpdatedOn, t.Subject) + continue + } + c.dispatchTask(ctx, state, writer, gitea, t) + } + return nil +} + +// dispatchTask runs one turn end-to-end for t: mark -> turn -> REPORT -> +// note -> status -> gitea -> refresh. Every step logs one stdout line plus +// one JSONL event; a failed step never kills the loop. +func (c *Conductor) dispatchTask(ctx context.Context, state *loopState, writer *writeback.RedmineWriter, gitea *writeback.GiteaWriter, t task.Task) { + // Mark BEFORE the turn: a failing turn must not hot-loop the poll. + _ = state.log(loopEvent{Type: evDispatch, TaskID: t.ID, UpdatedOn: t.UpdatedOn, Subject: t.Subject}) + fmt.Fprintf(c.out, "harness: loop: dispatch %s (updated %s): %q\n", t.ID, t.UpdatedOn, t.Subject) + + run, err := c.runTask(ctx, t) + if run != nil && run.turn != nil && run.reportPath != "" { + _ = state.log(loopEvent{ + Type: evReport, TaskID: t.ID, Model: run.decision.Model, + ReportPath: run.reportPath, StopReason: run.turn.StopReason, + }) + } + if err != nil { + _ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "turn: " + err.Error()}) + fmt.Fprintf(c.out, "harness: loop: task %s turn failed: %v (issue left for PMO; update the issue to retry)\n", t.ID, err) + return + } + + // Gitea commit (optional): the REPORT file into the repo. + if gitea != nil && run.reportPath != "" { + if err := gitea.CommitFile(ctx, repoPath(run.reportPath), run.reportBody, + fmt.Sprintf("harness: REPORT for %s task %s (%s)", c.cfg.Vertical, t.ID, run.decision.Model)); err != nil { + _ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "gitea commit: " + err.Error()}) + fmt.Fprintf(c.out, "harness: loop: gitea commit failed for %s: %v\n", t.ID, err) + } else { + _ = state.log(loopEvent{Type: evCommit, TaskID: t.ID, ReportPath: run.reportPath}) + fmt.Fprintf(c.out, "harness: loop: gitea: committed %s\n", repoPath(run.reportPath)) + } + } + + // SoR writeback: note the REPORT on the issue, then transition status. + if to, ok := c.cfg.Redmine.StatusMap[t.Status]; ok { + if err := writer.SetStatus(ctx, t.ID, to); err != nil { + _ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "status: " + err.Error()}) + fmt.Fprintf(c.out, "harness: loop: status transition failed for %s: %v\n", t.ID, err) + } else { + _ = state.log(loopEvent{Type: evStatus, TaskID: t.ID, StatusFrom: t.Status, StatusTo: to}) + fmt.Fprintf(c.out, "harness: loop: status %s -> %s on #%s\n", t.Status, to, t.ID) + } + } + if err := writer.AddNote(ctx, t.ID, run.reportBody); err != nil { + _ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "note: " + err.Error()}) + fmt.Fprintf(c.out, "harness: loop: note writeback failed for %s: %v\n", t.ID, err) + } else { + _ = state.log(loopEvent{Type: evNote, TaskID: t.ID, ReportPath: run.reportPath}) + fmt.Fprintf(c.out, "harness: loop: noted REPORT on #%s\n", t.ID) + } + + // Our own writebacks bump updated_on; refresh the dedup marker to the + // post-writeback value so the next scan does not re-trigger itself. + updated, err := writer.UpdatedOn(ctx, t.ID) + if err != nil { + _ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "refresh: " + err.Error()}) + fmt.Fprintf(c.out, "harness: loop: refresh failed for %s: %v (a re-dispatch may follow)\n", t.ID, err) + return + } + _ = state.log(loopEvent{Type: evRefresh, TaskID: t.ID, UpdatedOn: updated}) +} + +// taskRun is one completed (or failed) turn with everything the writebacks +// need: the routing decision, the turn result, and the REPORT file path + +// rendered body. +type taskRun struct { + task task.Task + decision models.Decision + turn *TurnResult + reportPath string + reportBody string +} + +// runTask is the shared per-task core of `once` and `loop`: routing -> +// bounded turn -> REPORT file. The returned error is the turn error (a +// partial REPORT may still exist); setup failures (routing, keys) return a +// nil taskRun. +func (c *Conductor) runTask(ctx context.Context, t task.Task) (*taskRun, error) { + decision, err := c.router.Resolve(t.Class) + if err != nil { + return nil, err + } + fmt.Fprintf(c.out, "harness: task %s (%s): %q class=%s -> %s -> %s\n", + t.ID, t.Source, t.Subject, t.Class, decision.Tier, decision.Model) + + client, err := c.llmClient() + if err != nil { + return nil, err + } + start := time.Now() + turn, err := c.turn(ctx, client, t, decision.Model) + run := &taskRun{task: t, decision: decision, turn: turn} + if err != nil { + if turn != nil && turn.Content != "" { + path, body, werr := c.writeReport(t, decision, turn, start, err) + if werr == nil { + run.reportPath, run.reportBody = path, body + } + } + return run, fmt.Errorf("%w: %v", ErrLLM, err) + } + path, body, werr := c.writeReport(t, decision, turn, start, nil) + if werr != nil { + return run, werr + } + run.reportPath, run.reportBody = path, body + return run, nil +} + +// repoPath maps a local REPORT path to its in-repo path (slash-separated, +// relative to the process CWD when possible, else the base name). +func repoPath(local string) string { + cwd, err := os.Getwd() + if err == nil { + if rel, err := filepath.Rel(cwd, local); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(filepath.Base(local)) +} + +func (c *Conductor) redmineHost() string { + u := c.cfg.Redmine.URL + if i := strings.Index(u, "://"); i >= 0 { + u = u[i+3:] + } + return strings.TrimSuffix(u, "/") +} diff --git a/internal/loop/daemon_test.go b/internal/loop/daemon_test.go new file mode 100644 index 0000000..ddab6fb --- /dev/null +++ b/internal/loop/daemon_test.go @@ -0,0 +1,415 @@ +package loop + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "ukrrs.com/mopac/harness/internal/config" +) + +// fakeRedmine is a mutable in-memory Redmine: /issues.json scope listing, +// PUT /issues/{id}.json (note + status), /issue_statuses.json, and +// GET /issues/{id}.json (updated_on). Notes bump updated_on like the real +// SoR, so the loop's refresh logic is exercised for real. +type fakeRedmine struct { + mu sync.Mutex + issues map[string]struct { + subject string + status string + updatedOn string + notes []string + } + statusIDs map[string]int + noted []string // issue ids that got a note, in order + srv *httptest.Server +} + +func newFakeRedmine(t *testing.T) *fakeRedmine { + t.Helper() + f := &fakeRedmine{ + issues: map[string]struct { + subject string + status string + updatedOn string + notes []string + }{}, + statusIDs: map[string]int{"New": 1, "In Progress": 2, "Done": 3}, + } + mux := http.NewServeMux() + mux.HandleFunc("GET /issues.json", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + var b strings.Builder + b.WriteString(`{"issues":[`) + first := true + for id, is := range f.issues { + if !first { + b.WriteString(",") + } + first = false + fmt.Fprintf(&b, `{"id":%s,"subject":%q,"description":"do the thing","updated_on":%q,"status":{"name":%q},"custom_fields":[{"name":"Class","value":"primary"}]}`, + id, is.subject, is.updatedOn, is.status) + } + b.WriteString(`]}`) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(b.String())) + }) + mux.HandleFunc("PUT /issues/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json") + var body struct { + Issue struct { + Notes string `json:"notes"` + StatusID int `json:"status_id"` + } `json:"issue"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + f.mu.Lock() + defer f.mu.Unlock() + is, ok := f.issues[id] + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + if body.Issue.Notes != "" { + is.notes = append(is.notes, body.Issue.Notes) + f.noted = append(f.noted, id) + } + if body.Issue.StatusID != 0 { + for name, sid := range f.statusIDs { + if sid == body.Issue.StatusID { + is.status = name + } + } + } + is.updatedOn = time.Now().UTC().Format(time.RFC3339) + f.issues[id] = is + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("GET /issue_statuses.json", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + var b strings.Builder + b.WriteString(`{"issue_statuses":[`) + first := true + for name, id := range f.statusIDs { + if !first { + b.WriteString(",") + } + first = false + fmt.Fprintf(&b, `{"id":%d,"name":%q}`, id, name) + } + b.WriteString(`]}`) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(b.String())) + }) + mux.HandleFunc("GET /issues/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json") + f.mu.Lock() + defer f.mu.Unlock() + is, ok := f.issues[id] + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + fmt.Fprintf(w, `{"issue":{"id":%s,"updated_on":%q}}`, id, is.updatedOn) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeRedmine) addIssue(id, subject, status, updatedOn string) { + f.mu.Lock() + defer f.mu.Unlock() + f.issues[id] = struct { + subject string + status string + updatedOn string + notes []string + }{subject: subject, status: status, updatedOn: updatedOn} +} + +func (f *fakeRedmine) setUpdatedOn(id, updatedOn string) { + f.mu.Lock() + defer f.mu.Unlock() + if is, ok := f.issues[id]; ok { + is.updatedOn = updatedOn + f.issues[id] = is + } +} + +func (f *fakeRedmine) status(id string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.issues[id].status +} + +func (f *fakeRedmine) noteCount(id string) int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.issues[id].notes) +} + +func loopTestConfig(t *testing.T, redmineURL, llmURL string) *config.Config { + t.Helper() + cfg := testConfig(t, llmURL) + cfg.Redmine.URL = redmineURL + cfg.Redmine.KeyRef = "literal:rm-key" + cfg.Redmine.ScopeQuery = "project=mopac" + cfg.Redmine.StatusMap = map[string]string{"In Progress": "Done"} + cfg.Loop.StateDir = filepath.Join(t.TempDir(), "state-loop") + cfg.Loop.PollIntervalSecs = 3600 // between-scan sleep; tests use --once scans + return cfg +} + +// runOneScan runs the daemon in --once mode against the given servers. +func runOneScan(t *testing.T, cfg *config.Config, dryRun bool) string { + t.Helper() + var out strings.Builder + cond, err := New(cfg, &out) + if err != nil { + t.Fatal(err) + } + if err := cond.RunLoop(context.Background(), LoopOpts{Once: true, DryRun: dryRun}); err != nil { + t.Fatalf("RunLoop: %v (output:\n%s)", err, out.String()) + } + return out.String() +} + +func TestLoopDispatchNoteStatusDedup(t *testing.T) { + rm := newFakeRedmine(t) + rm.addIssue("421", "Self-host: write the note", "In Progress", "2026-08-28T21:30:00Z") + f := newFakeLLM(t, textMsg("Report body: the turn ran.")) + cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) + + out := runOneScan(t, cfg, false) + + // One LLM turn, routed through the class map. + if f.requestCount() != 1 { + t.Fatalf("LLM calls = %d, want 1", f.requestCount()) + } + for _, want := range []string{ + "dispatch 421", + "noted REPORT on #421", + "status In Progress -> Done on #421", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if rm.status("421") != "Done" { + t.Errorf("issue status = %q, want Done", rm.status("421")) + } + if rm.noteCount("421") != 1 { + t.Fatalf("notes on #421 = %d, want 1", rm.noteCount("421")) + } + if body := rm.issues["421"].notes[0]; !strings.Contains(body, "Report body: the turn ran.") || + !strings.Contains(body, "# REPORT - teststack - 421") { + t.Errorf("note body wrong:\n%s", body) + } + + // REPORT file landed too. + reports, err := filepath.Glob(filepath.Join(cfg.ReportDir, "REPORT-teststack-421-*.md")) + if err != nil || len(reports) != 1 { + t.Fatalf("REPORT files = %v err=%v", reports, err) + } + + // Second scan: our own note bumped updated_on, but the refresh must + // have advanced the dedup marker -> no re-dispatch. + f2 := newFakeLLM(t, textMsg("must not run")) + cfg.LiteLLM.BaseURL = f2.srv.URL + out2 := runOneScan(t, cfg, false) + if f2.requestCount() != 0 { + t.Fatalf("second scan re-dispatched (LLM calls = %d)", f2.requestCount()) + } + if !strings.Contains(out2, "0 new/updated") { + t.Errorf("second scan should find nothing new:\n%s", out2) + } + if rm.noteCount("421") != 1 { + t.Errorf("second scan added notes: %d", rm.noteCount("421")) + } +} + +func TestLoopRedispatchOnIssueUpdate(t *testing.T) { + rm := newFakeRedmine(t) + rm.addIssue("421", "First edit", "In Progress", "2026-08-28T21:30:00Z") + f := newFakeLLM(t, textMsg("first reply")) + cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) + + runOneScan(t, cfg, false) + if f.requestCount() != 1 { + t.Fatalf("first scan LLM calls = %d", f.requestCount()) + } + + // PMO updates the issue (bumps updated_on): the loop re-releases it. + rm.setUpdatedOn("421", "2026-08-28T22:30:00Z") + out := runOneScan(t, cfg, false) + if !strings.Contains(out, "1 new/updated") { + t.Errorf("updated issue should be new:\n%s", out) + } + if f.requestCount() != 2 { + t.Errorf("LLM calls after update = %d, want 2", f.requestCount()) + } + if rm.noteCount("421") != 2 { + t.Errorf("notes after update = %d, want 2", rm.noteCount("421")) + } +} + +func TestLoopTurnFailureRecordedNoHotLoop(t *testing.T) { + rm := newFakeRedmine(t) + rm.addIssue("421", "Broken task", "In Progress", "2026-08-28T21:30:00Z") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "proxy down", http.StatusBadGateway) + })) + defer srv.Close() + cfg := loopTestConfig(t, rm.srv.URL, srv.URL) + + out := runOneScan(t, cfg, false) + if !strings.Contains(out, "turn failed") { + t.Errorf("output should record the failed turn:\n%s", out) + } + // No note, no status change on a failed turn... + if rm.noteCount("421") != 0 || rm.status("421") != "In Progress" { + t.Errorf("failed turn must not note/status: notes=%d status=%s", rm.noteCount("421"), rm.status("421")) + } + // ...and the next scan must NOT retry it (updated_on marker consumed). + out2 := runOneScan(t, cfg, false) + if !strings.Contains(out2, "0 new/updated") { + t.Errorf("failed turn hot-looped:\n%s", out2) + } +} + +func TestLoopDryRunDispatchesNothing(t *testing.T) { + rm := newFakeRedmine(t) + rm.addIssue("421", "Would-be task", "In Progress", "2026-08-28T21:30:00Z") + f := newFakeLLM(t, textMsg("unused")) + cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) + + out := runOneScan(t, cfg, true) + if f.requestCount() != 0 { + t.Errorf("dry-run made %d LLM calls", f.requestCount()) + } + if !strings.Contains(out, "would dispatch 421") { + t.Errorf("dry-run should print the would-be dispatch:\n%s", out) + } + if _, err := os.Stat(filepath.Join(cfg.Loop.StateDir, "loop.jsonl")); !os.IsNotExist(err) { + t.Errorf("dry-run must not write loop state") + } + // A dry-run must not consume the task: a real scan still dispatches. + runOneScan(t, cfg, false) + if f.requestCount() != 1 { + t.Errorf("LLM calls after real scan = %d, want 1", f.requestCount()) + } +} + +func TestLoopStateSurvivesRestart(t *testing.T) { + rm := newFakeRedmine(t) + rm.addIssue("421", "Persisted", "In Progress", "2026-08-28T21:30:00Z") + f := newFakeLLM(t, textMsg("reply")) + cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) + + runOneScan(t, cfg, false) + + // New conductor = process restart: state reloads from loop.jsonl, and + // the refresh line (post-note updated_on) is what dedups. + f2 := newFakeLLM(t, textMsg("must not run")) + cfg.LiteLLM.BaseURL = f2.srv.URL + runOneScan(t, cfg, false) + if f2.requestCount() != 0 { + t.Errorf("restart re-dispatched a processed issue") + } +} + +func TestLoopStatusMapMissLeavesStatus(t *testing.T) { + rm := newFakeRedmine(t) + rm.addIssue("421", "Unmapped status", "Review", "2026-08-28T21:30:00Z") + f := newFakeLLM(t, textMsg("reply")) + cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) + + out := runOneScan(t, cfg, false) + if strings.Contains(out, "status Review ->") { + t.Errorf("unmapped status must not transition:\n%s", out) + } + if rm.status("421") != "Review" { + t.Errorf("status changed: %q", rm.status("421")) + } + if rm.noteCount("421") != 1 { + t.Errorf("note should still land, got %d", rm.noteCount("421")) + } +} + +func TestLoopRequiresRedmineConfig(t *testing.T) { + cfg := testConfig(t, "http://unused") + cond, err := New(cfg, os.Stdout) + if err != nil { + t.Fatal(err) + } + err = cond.RunLoop(context.Background(), LoopOpts{Once: true}) + if err == nil || !strings.Contains(err.Error(), "[redmine] url is required") { + t.Fatalf("err = %v, want missing-redmine error", err) + } +} + +func TestLoopGiteaCommitStep(t *testing.T) { + rm := newFakeRedmine(t) + rm.addIssue("421", "Commit me", "In Progress", "2026-08-28T21:30:00Z") + f := newFakeLLM(t, textMsg("reply with body")) + + var mu sync.Mutex + var commits []map[string]any + var authHeaders []string + gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + authHeaders = append(authHeaders, r.Header.Get("Authorization")) + if r.Method == http.MethodGet { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + commits = append(commits, body) + w.Write([]byte(`{"content":{"path":"x"}}`)) + })) + defer gitea.Close() + + cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) + cfg.Gitea = config.GiteaConfig{ + URL: gitea.URL, KeyRef: "literal:gt-key", + Owner: "ukrrs", Repo: "reports", Branch: "main", + CommitReports: true, + } + out := runOneScan(t, cfg, false) + + mu.Lock() + defer mu.Unlock() + if len(commits) != 1 { + t.Fatalf("gitea commits = %d, want 1", len(commits)) + } + if authHeaders[0] != "token gt-key" { + t.Errorf("gitea auth = %q", authHeaders[0]) + } + path, _ := commits[0]["message"].(string) + if !strings.Contains(out, "gitea: committed") { + t.Errorf("output missing gitea commit line:\n%s", out) + } + if msg, _ := commits[0]["message"].(string); !strings.Contains(msg, "task 421") { + t.Errorf("commit message = %q", msg) + } + _ = path + if commits[0]["branch"] != "main" { + t.Errorf("commit branch = %v", commits[0]["branch"]) + } +} diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 3264950..045e651 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -1,6 +1,7 @@ // Package loop is the conductor: one bounded iteration per call -// (intake -> gate -> bounded turn -> REPORT writeback). Callers chain -// iterations by re-invoking; there is no daemon. +// (intake -> gate -> bounded turn -> REPORT writeback) plus the +// self-hosting daemon (`harness loop`) that drives iterations off the +// Redmine intake poll. package loop import ( @@ -32,6 +33,7 @@ type Conductor struct { cfg *config.Config router *models.Router bash *tools.BashTool + keys *config.KeyResolver out io.Writer } @@ -55,7 +57,7 @@ func New(cfg *config.Config, out io.Writer) (*Conductor, error) { return nil, err } } - return &Conductor{cfg: cfg, router: router, bash: bash, out: out}, nil + return &Conductor{cfg: cfg, router: router, bash: bash, keys: config.NewKeyResolver(cfg), out: out}, nil } // OnceOpts controls a single iteration. @@ -127,11 +129,11 @@ func (c *Conductor) Once(ctx context.Context, opts OnceOpts) (*OnceResult, error if err != nil { // Keep whatever content the turn produced before failing. if turn != nil && turn.Content != "" { - _, _ = c.writeReport(t, decision, turn, start, err) + _, _, _ = c.writeReport(t, decision, turn, start, err) } return res, fmt.Errorf("%w: %v", ErrLLM, err) } - path, err := c.writeReport(t, decision, turn, start, nil) + path, _, err := c.writeReport(t, decision, turn, start, nil) if err != nil { return res, err } @@ -227,7 +229,7 @@ func (c *Conductor) intake(ctx context.Context, opts OnceOpts) ([]task.Task, err if rc.URL == "" { return nil, fmt.Errorf("%w: no [redmine] url configured (use --demo for the demo issue)", ErrIntake) } - key, err := config.ResolveKeyRef(rc.KeyRef) + key, err := c.keys.Resolve(ctx, rc.KeyRef) if err != nil { return nil, fmt.Errorf("%w: redmine key: %v", ErrIntake, err) } @@ -241,7 +243,7 @@ func (c *Conductor) intake(ctx context.Context, opts OnceOpts) ([]task.Task, err } func (c *Conductor) llmClient() (*llm.Client, error) { - key, err := config.ResolveKeyRef(c.cfg.LiteLLM.KeyRef) + key, err := c.keys.Resolve(context.Background(), c.cfg.LiteLLM.KeyRef) if err != nil { return nil, fmt.Errorf("litellm key: %w", err) } @@ -253,7 +255,9 @@ func (c *Conductor) llmClient() (*llm.Client, error) { ), nil } -func (c *Conductor) writeReport(t task.Task, d models.Decision, turn *TurnResult, start time.Time, turnErr error) (string, error) { +// writeReport persists the turn as a REPORT file and returns its path plus +// the rendered body (the loop re-uses the body for the Redmine note). +func (c *Conductor) writeReport(t task.Task, d models.Decision, turn *TurnResult, start time.Time, turnErr error) (string, string, error) { r := writeback.Report{ Time: time.Now().UTC(), Vertical: c.cfg.Vertical, @@ -276,10 +280,10 @@ func (c *Conductor) writeReport(t task.Task, d models.Decision, turn *TurnResult } path, err := writeback.Write(c.cfg.ReportDir, r) if err != nil { - return "", err + return "", "", err } fmt.Fprintf(c.out, "harness: REPORT %s\n", path) - return path, nil + return path, r.Render(), nil } func (c *Conductor) printPlan(t task.Task, d models.Decision) { diff --git a/internal/loop/state.go b/internal/loop/state.go new file mode 100644 index 0000000..ceaa409 --- /dev/null +++ b/internal/loop/state.go @@ -0,0 +1,144 @@ +package loop + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Loop event types (one JSON line per action in loop.jsonl). +const ( + evDispatch = "dispatch" // turn started for task_id at updated_on + evReport = "report" // REPORT file written (or partial on failure) + evNote = "note" // REPORT noted back on the Redmine issue + evStatus = "status" // issue status transitioned per the config map + evCommit = "commit" // REPORT committed to gitea (optional step) + evRefresh = "refresh" // dedup marker advanced to post-writeback updated_on + evError = "error" // a step failed; loop continues +) + +// loopEvent is one line of the append-only loop log. It doubles as the +// dedup index source: dispatch/refresh lines carry (task_id, updated_on) +// pairs; the latest one per task wins. +type loopEvent struct { + TS time.Time `json:"ts"` + Type string `json:"type"` + TaskID string `json:"task_id,omitempty"` + UpdatedOn string `json:"updated_on,omitempty"` + Subject string `json:"subject,omitempty"` + Model string `json:"model,omitempty"` + ReportPath string `json:"report_path,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StatusFrom string `json:"status_from,omitempty"` + StatusTo string `json:"status_to,omitempty"` + Detail string `json:"detail,omitempty"` // human context; never secrets +} + +// loopState is the append-only loop log + dedup index: stateDir/loop.jsonl, +// one event per line. The index maps task id -> the updated_on it was last +// processed at; it is rebuilt from the file at open so restarts keep the +// exactly-once-reaction semantics (a torn tail line is skipped, not fatal). +type loopState struct { + mu sync.Mutex + f *os.File + path string + seen map[string]string + readOnly bool // dry-run: dedup checks work, writes are refused +} + +func openLoopState(stateDir string) (*loopState, error) { + if err := os.MkdirAll(stateDir, 0o700); err != nil { + return nil, fmt.Errorf("loop state dir: %w", err) + } + path := filepath.Join(stateDir, "loop.jsonl") + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, fmt.Errorf("open loop log: %w", err) + } + s := &loopState{path: path, f: f, seen: make(map[string]string)} + if err := s.loadIndex(); err != nil { + f.Close() + return nil, err + } + return s, nil +} + +// openLoopStateReadOnly builds the dedup index without creating or +// touching the log (the dry-run path must leave no trace). +func openLoopStateReadOnly(stateDir string) (*loopState, error) { + s := &loopState{ + path: filepath.Join(stateDir, "loop.jsonl"), + seen: make(map[string]string), + readOnly: true, + } + if err := s.loadIndex(); err != nil { + return nil, err + } + return s, nil +} + +func (s *loopState) loadIndex() error { + rf, err := os.Open(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read loop log: %w", err) + } + defer rf.Close() + sc := bufio.NewScanner(rf) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + for sc.Scan() { + var ev loopEvent + if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { + continue // torn/malformed tail line; dedup stays conservative + } + if ev.Type == evDispatch || ev.Type == evRefresh { + s.seen[ev.TaskID] = ev.UpdatedOn + } + } + return sc.Err() +} + +// processed reports whether taskID was already handled at this updated_on. +// Empty updated_on (demo/local tasks) dedups on the id alone. +func (s *loopState) processed(taskID, updatedOn string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.seen[taskID] == updatedOn +} + +// log appends one event line and, for dispatch/refresh events, advances the +// dedup marker. In read-only mode (dry-run) it is a no-op. +func (s *loopState) log(ev loopEvent) error { + if s.readOnly { + return nil + } + ev.TS = time.Now().UTC() + s.mu.Lock() + defer s.mu.Unlock() + line, err := json.Marshal(ev) + if err != nil { + return fmt.Errorf("encode loop event: %w", err) + } + if _, err := s.f.Write(append(line, '\n')); err != nil { + return fmt.Errorf("append loop log: %w", err) + } + if ev.Type == evDispatch || ev.Type == evRefresh { + s.seen[ev.TaskID] = ev.UpdatedOn + } + return nil +} + +func (s *loopState) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.f == nil { + return nil + } + return s.f.Close() +} diff --git a/internal/task/task.go b/internal/task/task.go index 41393ec..00fc7fd 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -9,4 +9,12 @@ type Task struct { Prompt string `json:"prompt"` Class string `json:"class"` Source string `json:"source"` // "redmine" | "demo" + + // UpdatedOn is the Redmine `updated_on` at intake. The `harness loop` + // dedups on issue id + updated_on: an issue update re-releases the + // task; an unchanged issue is never re-dispatched. + UpdatedOn string `json:"updated_on,omitempty"` + // Status is the Redmine status NAME at intake ("In Progress", ...); + // the loop's status map keys off it. Empty for non-Redmine tasks. + Status string `json:"status,omitempty"` } diff --git a/internal/writeback/gitea.go b/internal/writeback/gitea.go new file mode 100644 index 0000000..e4f76ab --- /dev/null +++ b/internal/writeback/gitea.go @@ -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 +} diff --git a/internal/writeback/redmine.go b/internal/writeback/redmine.go new file mode 100644 index 0000000..93bad61 --- /dev/null +++ b/internal/writeback/redmine.go @@ -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] + "..." +} diff --git a/internal/writeback/redmine_test.go b/internal/writeback/redmine_test.go new file mode 100644 index 0000000..ddd6d2d --- /dev/null +++ b/internal/writeback/redmine_test.go @@ -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) + } +}