`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
126 lines
3.1 KiB
Go
126 lines
3.1 KiB
Go
// 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
|
|
}
|