harness: LiteLLM client, Redmine intake, REPORT writeback, gated bash tool
OpenAI-compatible chat client for the LiteLLM proxy (base_url normalization,
Bearer auth, retry/backoff on 429/5xx/transport, usage accounting) - stdlib
http only. Intake lists issues in scope from /issues.json with the task
class read from a configurable custom field. Writeback lands each turn as
REPORT-<vertical>-<task>-<ts>.md plus REPORT-latest.md (atomic rename) with
model/tier/token telemetry. The bash tool ports maki's permission semantics
without tree-sitter: segment-by-segment compound-command checks, deny beats
allow, word-boundary "cmd *" matching, $()/backtick/subshell denied, output
truncation, per-command timeout with process-group cleanup.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package intake
|
||||
|
||||
import (
|
||||
"git.knownelement.com/reachableceo/MOPAC/harness/internal/config"
|
||||
"git.knownelement.com/reachableceo/MOPAC/harness/internal/task"
|
||||
)
|
||||
|
||||
// DemoTask builds the MVP demo issue from the [demo] config section: the
|
||||
// smoke path that exercises the whole loop (intake -> turn -> REPORT) without
|
||||
// needing a reachable Redmine.
|
||||
func DemoTask(d config.DemoConfig) task.Task {
|
||||
return task.Task{
|
||||
ID: d.ID,
|
||||
Subject: d.Subject,
|
||||
Prompt: d.Prompt,
|
||||
Class: d.Class,
|
||||
Source: "demo",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package intake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"git.knownelement.com/reachableceo/MOPAC/harness/internal/config"
|
||||
)
|
||||
|
||||
func TestListTasks(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotQuery url.Values
|
||||
var gotKey string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.Query()
|
||||
gotKey = r.Header.Get("X-Redmine-API-Key")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"issues": [
|
||||
{
|
||||
"id": 421,
|
||||
"subject": "Study crush notes",
|
||||
"description": "Read the notes and summarize.",
|
||||
"custom_fields": [{"name": "Class", "value": "study"}]
|
||||
},
|
||||
{
|
||||
"id": 422,
|
||||
"subject": "No class field",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.RedmineConfig{
|
||||
URL: srv.URL,
|
||||
ScopeQuery: "project=mopac&status_id=released",
|
||||
ClassField: "Class",
|
||||
DefaultClass: "primary",
|
||||
Limit: 25,
|
||||
}
|
||||
tasks, err := NewRedmineClient(cfg, "rm-key").ListTasks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasks: %v", err)
|
||||
}
|
||||
if gotPath != "/issues.json" {
|
||||
t.Errorf("path = %s", gotPath)
|
||||
}
|
||||
if gotKey != "rm-key" {
|
||||
t.Errorf("API key header = %q", gotKey)
|
||||
}
|
||||
if gotQuery.Get("project") != "mopac" || gotQuery.Get("limit") != "25" {
|
||||
t.Errorf("query = %v", gotQuery)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("tasks = %d, want 2", len(tasks))
|
||||
}
|
||||
if tasks[0].ID != "421" || tasks[0].Class != "study" || tasks[0].Source != "redmine" {
|
||||
t.Errorf("task0 = %+v", tasks[0])
|
||||
}
|
||||
// Missing class falls back to default; empty description falls back to subject.
|
||||
if tasks[1].Class != "primary" || tasks[1].Prompt != "No class field" {
|
||||
t.Errorf("task1 = %+v", tasks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := config.RedmineConfig{URL: srv.URL, ScopeQuery: "project=x", ClassField: "Class", DefaultClass: "primary"}
|
||||
if _, err := NewRedmineClient(cfg, "k").ListTasks(context.Background()); err == nil {
|
||||
t.Fatal("expected HTTP error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemoTask(t *testing.T) {
|
||||
d := config.DemoConfig{
|
||||
ID: "demo-1", Subject: "MVP demo", Prompt: "tell me about yourself", Class: "primary",
|
||||
}
|
||||
tk := DemoTask(d)
|
||||
if tk.Source != "demo" || tk.Prompt != "tell me about yourself" || tk.Class != "primary" {
|
||||
t.Errorf("DemoTask = %+v", tk)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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"
|
||||
|
||||
"git.knownelement.com/reachableceo/MOPAC/harness/internal/config"
|
||||
"git.knownelement.com/reachableceo/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"`
|
||||
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",
|
||||
})
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user