`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
118 lines
3.0 KiB
Go
118 lines
3.0 KiB
Go
// Package intake turns released scope into TASK records: Redmine issues in
|
|
// scope today; local inbox files later.
|
|
package intake
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"ukrrs.com/mopac/harness/internal/config"
|
|
"ukrrs.com/mopac/harness/internal/task"
|
|
)
|
|
|
|
// RedmineClient lists issues in the configured scope via /issues.json.
|
|
type RedmineClient struct {
|
|
cfg config.RedmineConfig
|
|
key string
|
|
http *http.Client
|
|
limit int
|
|
}
|
|
|
|
// NewRedmineClient builds a client from config and a resolved API key.
|
|
func NewRedmineClient(cfg config.RedmineConfig, key string) *RedmineClient {
|
|
return &RedmineClient{
|
|
cfg: cfg,
|
|
key: key,
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
limit: cfg.Limit,
|
|
}
|
|
}
|
|
|
|
// ListTasks returns the issues in scope, in the order Redmine returns them.
|
|
// The task class comes from the configured custom field, falling back to the
|
|
// configured default class.
|
|
func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) {
|
|
q := c.cfg.ScopeQuery
|
|
if q == "" && c.cfg.ScopeQueryID > 0 {
|
|
q = "query_id=" + strconv.Itoa(c.cfg.ScopeQueryID)
|
|
}
|
|
if !strings.Contains(q, "limit=") && c.limit > 0 {
|
|
q += "&limit=" + strconv.Itoa(c.limit)
|
|
}
|
|
url := strings.TrimSuffix(c.cfg.URL, "/") + "/issues.json?" + q
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("X-Redmine-API-Key", c.key)
|
|
resp, err := c.http.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("redmine request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<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, truncate(string(raw), 300))
|
|
}
|
|
|
|
var payload struct {
|
|
Issues []struct {
|
|
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"`
|
|
} `json:"custom_fields"`
|
|
} `json:"issues"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return nil, fmt.Errorf("redmine decode: %w", err)
|
|
}
|
|
|
|
tasks := make([]task.Task, 0, len(payload.Issues))
|
|
for _, is := range payload.Issues {
|
|
class := c.cfg.DefaultClass
|
|
for _, cf := range is.CustomFields {
|
|
if strings.EqualFold(cf.Name, c.cfg.ClassField) && cf.Value != "" {
|
|
class = cf.Value
|
|
}
|
|
}
|
|
prompt := is.Description
|
|
if prompt == "" {
|
|
prompt = is.Subject
|
|
}
|
|
tasks = append(tasks, task.Task{
|
|
ID: strconv.Itoa(is.ID),
|
|
Subject: is.Subject,
|
|
Prompt: prompt,
|
|
Class: class,
|
|
Source: "redmine",
|
|
UpdatedOn: is.UpdatedOn,
|
|
Status: is.Status.Name,
|
|
})
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|