`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
156 lines
4.6 KiB
Go
156 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|