feat(forge_client): port to FULL Go — dichotomy wave

This commit is contained in:
Nick committed 2026-10-12 03:46:49 +01:00
1 parent 04583cd26a
commit 797bb63f3c
8 files changed
+3950

No files matched your search

+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""Truth generator for the forge_client dichotomy port (#2479 tranche, GO).
Runs the LIVE Python oracle (src/rune/forge_client.py) with the httpx
transport fully scripted, exercises every public function and every branch,
and freezes inputs + expected outputs to src/go/forge_client/truth.json
(consumed VERBATIM by the Go battery).
No clock, no store, no env leakage: every case is hermetic — the transport
is a scripted fake (never a real socket), sleep is a recording fake, and
os.environ is snapshotted/restored around every selection case.
Scripted entry shapes:
{"status": N, "payload": {...}} → FakeResponse(N, payload)
{"exc": "connect" | "connect_timeout" | "read_timeout"} → scripted
httpx exception (connect-phase vs non-connect transport, the
NOISE-vs-SIGNAL axis of _request).
"""
import json
import os
import sys
from pathlib import Path
from unittest.mock import patch
import httpx
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "src" / "go" / "forge_client" / "truth.json"
sys.path.insert(0, str(REPO / "src"))
import rune.forge_client as fc # noqa: E402
EXC = {
"connect": httpx.ConnectError,
"connect_timeout": httpx.ConnectTimeout,
"read_timeout": httpx.ReadTimeout,
}
rows = []
coverage = set()
def add(name, op, inp, want, covered):
rows.append({"name": name, "op": op, "input": inp, "want": want})
coverage.update(covered)
class FakeResponse:
def __init__(self, status=200, payload=None):
self.status_code = status
self._payload = payload if payload is not None else {}
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
raise httpx.HTTPStatusError(
f"error {self.status_code}",
request=httpx.Request("GET", "https://x"),
response=httpx.Response(self.status_code),
)
def make_fake(method, script, rec):
pending = list(script)
def fake(url, **kw):
rec["requests"].append(
{
"method": method,
"url": url,
"headers": kw.get("headers"),
"json": kw.get("json"),
"timeout": kw.get("timeout"),
}
)
if not pending:
raise AssertionError(f"script exhausted at request {len(rec['requests'])}")
entry = pending.pop(0)
if "exc" in entry:
raise EXC[entry["exc"]]("scripted")
return FakeResponse(entry.get("status", 200), entry.get("payload"))
return fake
def blank_rec():
return {"requests": [], "sleeps": [], "error": None}
def finish_rec(rec, result):
return {
"result": result,
"requests": rec["requests"],
"sleeps": rec["sleeps"],
"error": rec["error"],
}
def call_case(name, method_name, script, kwargs, *, url="https://forge.example.com",
token="tok", owner="o", repo="r", covered=()):
rec = blank_rec()
adapter = fc.GiteaForgejoAdapter(url, token, owner=owner, repo=repo)
with patch("rune.forge_client.httpx.get", make_fake("GET", script, rec)), patch(
"rune.forge_client.httpx.post", make_fake("POST", script, rec)
), patch("rune.forge_client.time.sleep", rec["sleeps"].append):
try:
result = getattr(adapter, method_name)(**kwargs)
except Exception as e: # noqa: BLE001 — the kind IS the information
rec["error"] = f"httpx.{type(e).__name__}"
result = None
add(
name,
"call",
{
"method": method_name,
"url": url,
"token": token,
"owner": owner,
"repo": repo,
"script": script,
"kwargs": kwargs,
},
finish_rec(rec, result),
("GiteaForgejoAdapter." + method_name,) + covered,
)
def request_case(name, method, script, *, headers=None, json_body=None, timeout=30,
retries=2, backoff=0.5, covered=()):
rec = blank_rec()
with patch("rune.forge_client.httpx.get", make_fake("GET", script, rec)), patch(
"rune.forge_client.httpx.post", make_fake("POST", script, rec)
):
try:
result = fc._request(
method,
"https://x/api",
headers=headers,
json=json_body,
timeout=timeout,
retries=retries,
backoff=backoff,
sleep=rec["sleeps"].append,
).json()
except Exception as e: # noqa: BLE001
rec["error"] = f"httpx.{type(e).__name__}"
result = None
add(
name,
"request",
{
"method": method,
"url": "https://x/api",
"headers": headers,
"json": json_body,
"timeout": timeout,
"retries": retries,
"backoff": backoff,
"script": script,
},
finish_rec(rec, result),
("_request",) + covered,
)
def select_case(name, config, env, covered=()):
saved = {k: os.environ.get(k) for k in
("FORGE_URL", "FORGE_TOKEN", "FORGE_OWNER", "FORGE_REPO")}
try:
for k in ("FORGE_URL", "FORGE_TOKEN", "FORGE_OWNER", "FORGE_REPO"):
os.environ.pop(k, None)
os.environ.update({k: v for k, v in env.items() if v is not None})
client = fc.create_forge_client(config)
if isinstance(client, fc.NullAdapter):
want = {"adapter": "NullAdapter", "base_url": None, "token": None}
else:
want = {
"adapter": "GiteaForgejoAdapter",
"base_url": client._base_url,
"token": client._token,
}
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
add(name, "select", {"config": config, "env": env}, want,
("create_forge_client",) + covered)
def null_case(name, method_name, args):
client = fc.NullAdapter()
result = getattr(client, method_name)(*args)
add(name, "null", {"method": method_name, "args": args}, result,
("NullAdapter." + method_name,))
# ── create_forge_client: every selection branch ─────────────────────────────
select_case("select:none", None, {})
select_case("select:env-configured", {}, {"FORGE_URL": "https://forge.example.com",
"FORGE_TOKEN": "test_token"})
select_case("select:config-keys",
{"forge.url": "https://cfg.example.com", "forge.token": "cfg_tok",
"forge.owner": "cfg_owner", "forge.repo": "cfg_repo"}, {})
select_case("select:config-beats-env",
{"forge.url": "https://cfg.example.com", "forge.token": "cfg_tok"},
{"FORGE_URL": "https://env.example.com", "FORGE_TOKEN": "env_tok"})
select_case("select:owner-repo-defaults", {},
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token"})
select_case("select:owner-repo-env", {},
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token",
"FORGE_OWNER": "env_owner", "FORGE_REPO": "env_repo"})
select_case("select:trailing-slash-strip", {},
{"FORGE_URL": "https://forge.example.com///", "FORGE_TOKEN": "test_token"})
select_case("select:empty-cfg-url-falls-to-env",
{"forge.url": "", "forge.token": ""},
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token"})
select_case("select:cfg-url-missing-token", {"forge.url": "https://cfg.example.com"}, {})
select_case("select:cfg-token-missing-url", {"forge.token": "cfg_tok"}, {})
select_case("select:owner-empty-env", {},
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token",
"FORGE_OWNER": ""})
# ── NullAdapter: every no-op shape ──────────────────────────────────────────
null_case("null:create-issue", "create_issue", ["t", "b"])
null_case("null:add-comment", "add_comment", [3, "c"])
null_case("null:get-issue-state", "get_issue_state", [7])
null_case("null:list-open-issues", "list_open_issues", [])
null_case("null:search-issues", "search_issues", ["q"])
# ── GiteaForgejoAdapter: every method + happy paths ─────────────────────────
call_case("call:create-issue", "create_issue",
[{"status": 200, "payload": {"number": 42, "state": "open"}}],
{"title": "Test", "body": "Body"})
call_case("call:create-issue-201", "create_issue",
[{"status": 201, "payload": {"number": 1, "title": "t"}}],
{"title": "t", "body": "b"})
call_case("call:create-issue-4xx", "create_issue",
[{"status": 422, "payload": {}}], {"title": "dup", "body": "b"})
call_case("call:add-comment", "add_comment",
[{"status": 200, "payload": {"id": 9, "body": "c"}}],
{"issue_number": 5, "body": "c"})
call_case("call:get-issue-state", "get_issue_state",
[{"status": 200, "payload": {"number": 9, "state": "open"}}],
{"issue_number": 9})
call_case("call:list-open-issues", "list_open_issues",
[{"status": 200, "payload": [{"number": 1}, {"number": 2}]}], {})
call_case("call:search-single-page", "search_issues",
[{"status": 200, "payload": [{"number": 3, "title": "[auto-failure:cron:Connection]"}]}],
{"query": "[auto-failure:cron:Connection]"}, covered=("GiteaForgejoAdapter.search_issues.pagination",))
call_case("call:search-paginates", "search_issues",
[{"status": 200, "payload": [{"number": n, "title": f"issue {n}"} for n in range(50)]},
{"status": 200, "payload": [{"number": 100, "title": "[auto-failure:cron:Connection] cron"}]}],
{"query": "[auto-failure:cron:Connection]"},
covered=("GiteaForgejoAdapter.search_issues.pagination",))
call_case("call:search-filters-title-body", "search_issues",
[{"status": 200, "payload": [
{"number": 1, "title": "[auto-failure:cron:Connection] cron", "body": "x"},
{"number": 2, "title": "plain title", "body": "quoted [auto-failure:cron:Connection] in body"},
{"number": 3, "title": "mention of connection only", "body": "unrelated"}]}],
{"query": "[auto-failure:cron:Connection]"},
covered=("GiteaForgejoAdapter.search_issues.client-filter",))
call_case("call:search-no-hits", "search_issues",
[{"status": 200, "payload": [{"number": 1, "title": "unrelated"}]}],
{"query": "[auto-failure:cron:Connection]"})
call_case("call:search-exact-50-then-empty", "search_issues",
[{"status": 200, "payload": [{"number": n, "title": "noise"} for n in range(50)]},
{"status": 200, "payload": []}],
{"query": "[auto-failure:cron:Connection]"},
covered=("GiteaForgejoAdapter.search_issues.pagination",))
call_case("call:create-issue-retry-connect", "create_issue",
[{"exc": "connect"}, {"status": 200, "payload": {"number": 7}}],
{"title": "t", "body": "b"}, covered=("_request",))
call_case("call:get-issue-state-retry-5xx", "get_issue_state",
[{"status": 502}, {"status": 200, "payload": {"number": 9, "state": "open"}}],
{"issue_number": 9}, covered=("_request",))
call_case("call:get-issue-state-4xx", "get_issue_state",
[{"status": 404, "payload": {}}], {"issue_number": 404}, covered=("_request",))
# ── _request: every retry branch (noise vs signal) ──────────────────────────
request_case("request:get-ok", "GET", [{"status": 200, "payload": {"ok": True}}])
request_case("request:get-retries-connect", "GET",
[{"exc": "connect"}, {"exc": "connect"},
{"status": 200, "payload": {"ok": True}}])
request_case("request:get-connect-exhaust", "GET",
[{"exc": "connect"}, {"exc": "connect"}, {"exc": "connect"}])
request_case("request:get-4xx-no-retry", "GET", [{"status": 422}])
request_case("request:get-5xx-retry-then-ok", "GET",
[{"status": 502}, {"status": 503}, {"status": 200, "payload": {"n": 3}}])
request_case("request:get-5xx-exhaust", "GET", [{"status": 502}, {"status": 503}, {"status": 504}])
request_case("request:get-read-timeout-retries", "GET",
[{"exc": "read_timeout"}, {"status": 200, "payload": {"ok": True}}])
request_case("request:get-read-timeout-exhaust", "GET",
[{"exc": "read_timeout"}, {"exc": "read_timeout"}, {"exc": "read_timeout"}])
request_case("request:post-ok", "POST", [{"status": 200, "payload": {"a": 1}}],
json_body={"a": 1})
request_case("request:post-connect-retry", "POST",
[{"exc": "connect_timeout"}, {"status": 201, "payload": {"number": 7}}],
json_body={"a": 1})
request_case("request:post-connect-exhaust", "POST",
[{"exc": "connect"}, {"exc": "connect"}, {"exc": "connect"}],
json_body={"a": 1})
request_case("request:post-read-timeout-no-retry", "POST",
[{"exc": "read_timeout"}], json_body={"a": 1})
request_case("request:post-5xx-no-retry", "POST", [{"status": 502}], json_body={"a": 1})
request_case("request:post-4xx-no-retry", "POST", [{"status": 422}], json_body={"a": 1})
request_case("request:backoff-exponential", "GET",
[{"exc": "connect"}, {"exc": "connect"}, {"status": 200}])
request_case("request:custom-retries-backoff", "GET",
[{"exc": "connect"}, {"status": 200}], retries=1, backoff=0.25)
request_case("request:timeout-passthrough", "GET",
[{"status": 200}], timeout=17)
request_case("request:headers-json-capture", "POST",
[{"status": 200, "payload": {}}],
headers={"Authorization": "token t", "Content-Type": "application/json"},
json_body={"title": "t", "body": "b"})
request_case("request:lowercase-method", "post",
[{"status": 200, "payload": {"made": "it"}}], json_body={"k": "v"})
# ── freeze ───────────────────────────────────────────────────────────────────
EXPECTED = {
"create_forge_client", "_request",
"NullAdapter.create_issue", "NullAdapter.add_comment", "NullAdapter.get_issue_state",
"NullAdapter.list_open_issues", "NullAdapter.search_issues",
"GiteaForgejoAdapter.create_issue", "GiteaForgejoAdapter.add_comment",
"GiteaForgejoAdapter.get_issue_state", "GiteaForgejoAdapter.list_open_issues",
"GiteaForgejoAdapter.search_issues",
}
missing = EXPECTED - coverage
if missing:
sys.exit(f"COVERAGE GAP — public functions not exercised: {sorted(missing)}")
doc = {
"frozen_ts": "2026-10-12T02:17:44Z",
"oracle": "src/rune/forge_client.py",
"rows": rows,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"frozen {len(rows)} rows → {OUT}")
print("covered:", ", ".join(sorted(coverage)))
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Open the #2479 dichotomy-wave construction contract for forge_client (GO port).
Full lifecycle walk in ONE process (shared-store clobber hazard)."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from rune.construction_interface import ConstructionInterface
iface = ConstructionInterface(builder="factory-wave")
r = iface.submit_need(
"Port src/rune/forge_client.py to FULL Go (dichotomy factory, issue "
"#2479 tranche): name-preserving port of _request (noise-vs-signal "
"retry semantics), NullAdapter, GiteaForgejoAdapter and "
"create_forge_client with HTTP/sleep/env behind injectable seams, "
"frozen-truth battery generated against the live Python oracle, live "
"parity 100%, Phase B language-advantage pass.",
origin="builder",
)
print("submit_need:", json.dumps(r)[:400])
if not r.get("success"):
print("NEED FAILED — aborting")
sys.exit(1)
cid = r["ids"]["contract_id"]
print("contract_id:", cid)
for phase in ("approved", "building", "verifying"):
t = iface.transition(cid, phase)
print(f"transition->{phase}:", json.dumps(t)[:300])
if not t.get("success"):
sys.exit(1)
print("CONTRACT", cid, "IN VERIFYING (close happens at delivery with SHAs)")
+484
View File
@@ -0,0 +1,484 @@
// Package main — forge_client: the FULL Go port of src/rune/forge_client.py
// (dichotomy wave #2479 tranche).
//
// Name-preserving 1:1 port: _request (retry where failure is NOISE, never
// where failure is the SIGNAL), ForgeClient protocol, NullAdapter,
// GiteaForgejoAdapter, create_forge_client. All IO sits behind injectable
// seams so the frozen-truth battery can script the transport byte-for-byte:
//
// - transport: Sender interface (default HTTPXSender over net/http)
// - sleep: RequestOptions.Sleep (default time.Sleep)
// - env: LookupEnv var (default os.LookupEnv)
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// ── Defaults (mirroring the Python signature defaults) ──────────────────────
const (
DefaultTimeout = 30 * time.Second
DefaultRetries = 2
DefaultBackoff = 500 * time.Millisecond
SearchPageSize = 50
)
// ── Seams ───────────────────────────────────────────────────────────────────
// Response — the httpx.Response surface the port needs: status + JSON body.
type Response struct {
StatusCode int
Body []byte
}
// JSON decodes the body the way r.json() does; numbers stay json.Number so
// integer-valued fields render exactly like the Python oracle's values.
func (r *Response) JSON() (any, error) {
dec := json.NewDecoder(bytes.NewReader(r.Body))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
return v, nil
}
// Sender — injectable transport seam (httpx.get / httpx.post call sites).
type Sender interface {
Post(url string, headers map[string]string, jsonBody any, timeout time.Duration) (*Response, error)
Get(url string, headers map[string]string, timeout time.Duration) (*Response, error)
}
// ForgeTransportError mirrors httpx.TransportError's split: connect-phase
// failures (ConnectError / ConnectTimeout — the request never reached the
// server) vs non-connect transport failures (ReadTimeout & co — the server
// MAY have seen it). The Kind strings are the frozen truth vocabulary.
type ForgeTransportError struct {
Kind string // httpx.ConnectError | httpx.ConnectTimeout | httpx.ReadTimeout | httpx.TransportError
Msg string
}
func (e *ForgeTransportError) Error() string { return e.Kind + ": " + e.Msg }
// ConnectPhase reports whether a retry can never double-create.
func (e *ForgeTransportError) ConnectPhase() bool {
return e.Kind == "httpx.ConnectError" || e.Kind == "httpx.ConnectTimeout"
}
// HTTPStatusError — the 4xx IS the information; never retried.
type HTTPStatusError struct{ StatusCode int }
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("httpx.HTTPStatusError: error %d", e.StatusCode)
}
// HTTPXSender — the default real transport over net/http.
type HTTPXSender struct{ Client *http.Client }
func NewHTTPXSender(client *http.Client) *HTTPXSender {
if client == nil {
client = http.DefaultClient
}
return &HTTPXSender{Client: client}
}
var DefaultSender Sender = NewHTTPXSender(nil)
func (s *HTTPXSender) Post(url string, headers map[string]string, jsonBody any, timeout time.Duration) (*Response, error) {
var body []byte
if jsonBody != nil {
b, err := json.Marshal(jsonBody)
if err != nil {
return nil, err
}
body = b
}
return s.do("POST", url, headers, body, timeout)
}
func (s *HTTPXSender) Get(url string, headers map[string]string, timeout time.Duration) (*Response, error) {
return s.do("GET", url, headers, nil, timeout)
}
func (s *HTTPXSender) do(method, url string, headers map[string]string, body []byte, timeout time.Duration) (*Response, error) {
ctx := context.Background()
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, rd)
if err != nil {
return nil, &ForgeTransportError{Kind: "httpx.TransportError", Msg: err.Error()}
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := s.Client.Do(req)
if err != nil {
return nil, &ForgeTransportError{Kind: classifyTransportErr(err), Msg: err.Error()}
}
defer resp.Body.Close()
b, rerr := io.ReadAll(resp.Body)
if rerr != nil {
return nil, &ForgeTransportError{Kind: "httpx.TransportError", Msg: rerr.Error()}
}
return &Response{StatusCode: resp.StatusCode, Body: b}, nil
}
// classifyTransportErr maps net/http failures onto the httpx vocabulary.
// Best-effort by nature (net/http does not label phases); dial and TLS
// handshake failures are connect-phase, read-side timeouts are not.
func classifyTransportErr(err error) string {
var oe *net.OpError
if errors.As(err, &oe) {
switch oe.Op {
case "dial":
return "httpx.ConnectError"
}
if strings.Contains(strings.ToLower(oe.Error()), "handshake") {
return "httpx.ConnectError"
}
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
if errors.As(err, &oe) && oe.Op == "dial" {
return "httpx.ConnectTimeout"
}
return "httpx.ReadTimeout"
}
if errors.Is(err, context.DeadlineExceeded) {
return "httpx.ReadTimeout"
}
return "httpx.TransportError"
}
// ── _request ────────────────────────────────────────────────────────────────
// RequestOptions — the keyword surface of the Python _request.
type RequestOptions struct {
Headers map[string]string
JSON any
Timeout time.Duration
Retries int
Backoff time.Duration
Sleep func(time.Duration)
}
// DefaultRequestOptions — timeout=30, retries=2, backoff=0.5.
func DefaultRequestOptions() RequestOptions {
return RequestOptions{Timeout: DefaultTimeout, Retries: DefaultRetries, Backoff: DefaultBackoff}
}
var DefaultSleep = time.Sleep
// Request — resilient single-request path: retry where failure is NOISE,
// never where failure is the SIGNAL.
//
// - GET: retries on connect-phase errors AND any non-connect transport
// error and 5xx status (transient infrastructure failure; idempotent).
// - POST: retries ONLY on connect-phase errors — the request never
// reached the server, so a retry cannot double-create. Read timeouts /
// write errors / 5xx on a POST are NOT retried.
// - 4xx: never retried — the client error IS the information.
// - Attempts: retries+1 total. Backoff backoff*2**n before retry n+1;
// never a sleep before the first attempt.
func Request(sender Sender, method, url string, opts RequestOptions) (*Response, error) {
sleeper := opts.Sleep
if sleeper == nil {
sleeper = DefaultSleep
}
isPost := strings.EqualFold(method, "POST")
for attempt := 0; attempt <= opts.Retries; attempt++ {
var resp *Response
var err error
if isPost {
resp, err = sender.Post(url, opts.Headers, opts.JSON, opts.Timeout)
} else {
resp, err = sender.Get(url, opts.Headers, opts.Timeout)
}
if err != nil {
var terr *ForgeTransportError
if errors.As(err, &terr) && terr.ConnectPhase() {
if attempt < opts.Retries {
sleeper(opts.Backoff * (1 << attempt))
continue
}
return nil, err
}
// Non-connect transport error (read/write): only safe to retry
// when the call is idempotent.
if !isPost && attempt < opts.Retries {
sleeper(opts.Backoff * (1 << attempt))
continue
}
return nil, err
}
if resp.StatusCode >= 500 && !isPost && attempt < opts.Retries {
sleeper(opts.Backoff * (1 << attempt))
continue
}
if resp.StatusCode >= 400 {
return nil, &HTTPStatusError{StatusCode: resp.StatusCode}
}
return resp, nil
}
// Unreachable: every attempt either returns a successful response or
// raises (mirror of the Python AssertionError guard).
return nil, fmt.Errorf("forge_client: unreachable: Request always returns or raises")
}
// ── ForgeClient protocol ────────────────────────────────────────────────────
// ForgeClient mirrors the Python runtime_checkable Protocol: create issues,
// comment, check state, list, search.
type ForgeClient interface {
CreateIssue(title, body string) (any, error)
AddComment(issueNumber int, body string) (any, error)
GetIssueState(issueNumber int) (any, error)
ListOpenIssues() ([]any, error)
SearchIssues(query string) ([]any, error)
}
// ── NullAdapter ─────────────────────────────────────────────────────────────
// NullAdapter — no-op adapter for unconfigured/standalone use. Zero errors.
type NullAdapter struct{}
func (NullAdapter) CreateIssue(title, body string) (any, error) {
return map[string]any{"created": false, "reason": "no forge configured"}, nil
}
func (NullAdapter) AddComment(issueNumber int, body string) (any, error) {
return map[string]any{"created": false, "reason": "no forge configured"}, nil
}
func (NullAdapter) GetIssueState(issueNumber int) (any, error) {
return map[string]any{"state": "unknown"}, nil
}
func (NullAdapter) ListOpenIssues() ([]any, error) { return []any{}, nil }
func (NullAdapter) SearchIssues(query string) ([]any, error) { return []any{}, nil }
// ── GiteaForgejoAdapter ─────────────────────────────────────────────────────
// GiteaForgejoAdapter — /api/v1 paths (identical on both platforms).
type GiteaForgejoAdapter struct {
BaseURL string
Token string
Sender Sender
Sleep func(time.Duration)
}
func NewGiteaForgejoAdapter(url, token, owner, repo string) *GiteaForgejoAdapter {
return &GiteaForgejoAdapter{
BaseURL: strings.TrimRight(url, "/") + "/api/v1/repos/" + owner + "/" + repo,
Token: token,
Sender: DefaultSender,
}
}
func (a *GiteaForgejoAdapter) headers() map[string]string {
return map[string]string{
"Authorization": "token " + a.Token,
"Content-Type": "application/json",
}
}
func (a *GiteaForgejoAdapter) opts() RequestOptions {
o := DefaultRequestOptions()
o.Headers = a.headers()
o.Sleep = a.Sleep
return o
}
func (a *GiteaForgejoAdapter) CreateIssue(title, body string) (any, error) {
o := a.opts()
o.JSON = map[string]any{"title": title, "body": body}
r, err := Request(a.Sender, "POST", a.BaseURL+"/issues", o)
if err != nil {
return nil, err
}
return r.JSON()
}
func (a *GiteaForgejoAdapter) AddComment(issueNumber int, body string) (any, error) {
o := a.opts()
o.JSON = map[string]any{"body": body}
r, err := Request(a.Sender, "POST",
a.BaseURL+"/issues/"+strconv.Itoa(issueNumber)+"/comments", o)
if err != nil {
return nil, err
}
return r.JSON()
}
func (a *GiteaForgejoAdapter) GetIssueState(issueNumber int) (any, error) {
r, err := Request(a.Sender, "GET",
a.BaseURL+"/issues/"+strconv.Itoa(issueNumber), a.opts())
if err != nil {
return nil, err
}
return r.JSON()
}
func (a *GiteaForgejoAdapter) ListOpenIssues() ([]any, error) {
r, err := Request(a.Sender, "GET", a.BaseURL+"/issues?state=open&limit=50", a.opts())
if err != nil {
return nil, err
}
v, err := r.JSON()
if err != nil {
return nil, err
}
batch, _ := v.([]any)
if batch == nil {
batch = []any{}
}
return batch, nil
}
// SearchIssues — repo-scoped marker search over the paginated list endpoint.
//
// #122 gate fix: Gitea/Forgejo has NO repo-scoped /issues/search path
// (verified against live swagger) — that URL 404s on a real forge. The
// repo-scoped LIST endpoint supports state, type and q server-side and is
// paginated: q matches titles (and keywords), so the full marker is sent
// as q and a client-side contains-check keeps exactly the issues whose
// title OR body contains the marker.
func (a *GiteaForgejoAdapter) SearchIssues(query string) ([]any, error) {
results := []any{}
page := 1
for {
r, err := Request(a.Sender, "GET",
a.BaseURL+"/issues?"+searchQuery(query, page), a.opts())
if err != nil {
return nil, err
}
v, err := r.JSON()
if err != nil {
return nil, err
}
batch, _ := v.([]any)
results = append(results, batch...)
if len(batch) < SearchPageSize {
return filterMarker(results, query), nil
}
page++
}
}
// searchQuery — Python urlencode preserves insertion order; so does this.
func searchQuery(q string, page int) string {
return "state=open&type=issues&q=" + url.QueryEscape(q) +
"&limit=50&page=" + strconv.Itoa(page)
}
// filterMarker — `query in str(i.get("title","")) + str(i.get("body",""))`
// with Python str() semantics (None → "None").
func filterMarker(results []any, query string) []any {
out := []any{}
for _, item := range results {
m, _ := item.(map[string]any)
hay := pyDictStr(m, "title") + pyDictStr(m, "body")
if strings.Contains(hay, query) {
out = append(out, item)
}
}
return out
}
func pyDictStr(m map[string]any, key string) string {
if v, ok := m[key]; ok {
return pyStr(v)
}
return ""
}
func pyStr(v any) string {
switch t := v.(type) {
case nil:
return "None"
case string:
return t
case bool:
if t {
return "True"
}
return "False"
case json.Number:
return t.String()
default:
return fmt.Sprint(t)
}
}
// ── create_forge_client ─────────────────────────────────────────────────────
// LookupEnv — injectable environment seam (os.environ reads).
var LookupEnv = os.LookupEnv
func envOr(name, def string) string {
if v, ok := LookupEnv(name); ok {
return v
}
return def
}
func cfgStr(cfg map[string]any, key string) string {
if v, ok := cfg[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// CreateForgeClient — create a forge client from config or environment.
//
// Config keys: forge.url, forge.token, forge.owner, forge.repo.
// Falls back to FORGE_URL/FORGE_TOKEN/FORGE_OWNER/FORGE_REPO env vars.
// Unconfigured → NullAdapter (graceful dormant).
func CreateForgeClient(cfg map[string]any) ForgeClient {
if cfg == nil {
cfg = map[string]any{}
}
forgeURL := cfgStr(cfg, "forge.url")
if forgeURL == "" {
forgeURL = envOr("FORGE_URL", "")
}
token := cfgStr(cfg, "forge.token")
if token == "" {
token = envOr("FORGE_TOKEN", "")
}
if forgeURL == "" || token == "" {
return NullAdapter{}
}
owner := cfgStr(cfg, "forge.owner")
if owner == "" {
owner = envOr("FORGE_OWNER", "owner")
}
repo := cfgStr(cfg, "forge.repo")
if repo == "" {
repo = envOr("FORGE_REPO", "repo")
}
return NewGiteaForgejoAdapter(forgeURL, token, owner, repo)
}
+3
View File
@@ -0,0 +1,3 @@
module rune/forge_client
go 1.22
+26
View File
@@ -0,0 +1,26 @@
// Parity gate — replays the frozen oracle truth (truth.json) through the
// Go port and exits non-zero on any divergence. CI-invocable without a
// test harness.
package main
import (
"fmt"
"os"
)
func main() {
fails := RunTruth("truth.json")
if len(fails) > 0 {
for _, f := range fails {
fmt.Fprintln(os.Stderr, "DIVERGED", f)
}
fmt.Fprintf(os.Stderr, "forge_client: %d divergence(s) vs frozen oracle truth\n", len(fails))
os.Exit(1)
}
doc, err := loadTruth("truth.json")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("forge_client: parity replay OK — %d frozen rows exact vs the Python oracle\n", len(doc.Rows))
}
+534
View File
@@ -0,0 +1,534 @@
// Replay — the frozen-truth parity engine. Reads truth.json (generated by
// scripts/gen_forge_client_truth.py against the live Python oracle, httpx
// transport scripted, sleep recorded) and re-executes every row through
// this Go port, comparing canonically (sorted-key JSON).
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
"time"
)
// TruthDoc — the frozen truth file structure.
type TruthDoc struct {
FrozenTS string `json:"frozen_ts"`
Oracle string `json:"oracle"`
Rows []TruthRow `json:"rows"`
}
// TruthRow — one frozen case.
type TruthRow struct {
Name string `json:"name"`
Op string `json:"op"`
Input json.RawMessage `json:"input"`
Want json.RawMessage `json:"want"`
}
// RunTruth replays every row and returns a divergence report (empty on 100%).
func RunTruth(path string) []string {
doc, err := loadTruth(path)
if err != nil {
return []string{fmt.Sprintf("load truth: %v", err)}
}
var fails []string
for i := range doc.Rows {
if msg := compareOne(doc, &doc.Rows[i]); msg != "" {
fails = append(fails, doc.Rows[i].Name+": "+msg)
}
}
return fails
}
func loadTruth(path string) (*TruthDoc, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc TruthDoc
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
if len(doc.Rows) == 0 {
return nil, fmt.Errorf("truth.json contained no rows")
}
return &doc, nil
}
// compareOne replays one row and returns "" on parity, else a divergence
// description. Shared by RunTruth and the per-function battery tests.
func compareOne(doc *TruthDoc, row *TruthRow) string {
got, err := execRow(row)
if err != nil {
return fmt.Sprintf("error: %v", err)
}
wantStr, err := canonicalFromRaw(row.Want)
if err != nil {
return fmt.Sprintf("canonicalize want: %v", err)
}
gotStr, err := canonicalFromRaw([]byte(mustJSON(got)))
if err != nil {
return fmt.Sprintf("canonicalize got: %v", err)
}
if wantStr != gotStr {
return fmt.Sprintf("\n want: %s\n got: %s", wantStr, gotStr)
}
return ""
}
// ── scripted transport (mirrors the generator's fake httpx) ────────────────
type scriptEntry struct {
Status int
Payload any
Exc string
}
type scriptedSender struct {
entries []scriptEntry
requests *[]any
}
func excKind(exc string) string {
switch exc {
case "connect":
return "httpx.ConnectError"
case "connect_timeout":
return "httpx.ConnectTimeout"
case "read_timeout":
return "httpx.ReadTimeout"
default:
return "httpx.TransportError"
}
}
func (s *scriptedSender) record(method, url string, headers map[string]string, jsonBody any, timeout time.Duration) *scriptEntry {
req := map[string]any{
"method": method,
"url": url,
"headers": headers,
"json": jsonBody,
"timeout": timeout.Seconds(),
}
*s.requests = append(*s.requests, req)
if len(s.entries) == 0 {
panic(fmt.Sprintf("script exhausted at request %d (%s %s)", len(*s.requests), method, url))
}
entry := s.entries[0]
s.entries = s.entries[1:]
return &entry
}
func (s *scriptedSender) respond(entry *scriptEntry) (*Response, error) {
if entry.Exc != "" {
return nil, &ForgeTransportError{Kind: excKind(entry.Exc), Msg: "scripted"}
}
status := entry.Status
if status == 0 {
status = 200
}
body, err := json.Marshal(entry.Payload)
if err != nil {
return nil, err
}
if entry.Payload == nil {
body = []byte("{}")
}
return &Response{StatusCode: status, Body: body}, nil
}
func (s *scriptedSender) Post(url string, headers map[string]string, jsonBody any, timeout time.Duration) (*Response, error) {
return s.respond(s.record("POST", url, headers, jsonBody, timeout))
}
func (s *scriptedSender) Get(url string, headers map[string]string, timeout time.Duration) (*Response, error) {
return s.respond(s.record("GET", url, headers, nil, timeout))
}
// ── row execution ───────────────────────────────────────────────────────────
func decodeInput(raw json.RawMessage) (map[string]any, error) {
if len(raw) == 0 {
return map[string]any{}, nil
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("input not an object")
}
return m, nil
}
func numInt(v any) int {
switch t := v.(type) {
case json.Number:
n, err := t.Int64()
if err != nil {
panic(fmt.Sprintf("not an integer: %v", t))
}
return int(n)
case float64:
return int(t)
default:
panic(fmt.Sprintf("not a number: %v", v))
}
}
func numFloat(v any) float64 {
switch t := v.(type) {
case json.Number:
f, _ := t.Float64()
return f
case float64:
return t
default:
panic(fmt.Sprintf("not a number: %v", v))
}
}
// wantRecorder builds the {result, requests, sleeps, error} got-shape the
// frozen rows carry.
type recorder struct {
requests []any
sleeps []any
errKind string
}
func (r *recorder) sleep(d time.Duration) { r.sleeps = append(r.sleeps, d.Seconds()) }
func (r *recorder) got(result any) map[string]any {
return map[string]any{
"result": result,
"requests": r.requests,
"sleeps": r.sleeps,
"error": errKindOrNil(r.errKind),
}
}
func errKindOrNil(kind string) any {
if kind == "" {
return nil
}
return kind
}
func errorKind(err error) string {
var terr *ForgeTransportError
if errors.As(err, &terr) {
return terr.Kind
}
var serr *HTTPStatusError
if errors.As(err, &serr) {
return "httpx.HTTPStatusError"
}
return fmt.Sprintf("%T", err)
}
func execRow(row *TruthRow) (any, error) {
in, err := decodeInput(row.Input)
if err != nil {
return nil, fmt.Errorf("decode input: %w", err)
}
switch row.Op {
case "select":
return execSelect(in)
case "null":
return execNull(in)
case "call":
return execCall(in)
case "request":
return execRequest(in)
}
return nil, fmt.Errorf("unknown row op %s (%s)", row.Op, row.Name)
}
func execSelect(in map[string]any) (any, error) {
var cfg map[string]any
if v, ok := in["config"]; ok && v != nil {
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("select: config not an object")
}
cfg = m
}
env := map[string]string{}
if v, ok := in["env"].(map[string]any); ok {
for k, val := range v {
s, _ := val.(string)
env[k] = s
}
}
saved := LookupEnv
defer func() { LookupEnv = saved }()
LookupEnv = func(name string) (string, bool) {
v, ok := env[name]
return v, ok
}
client := CreateForgeClient(cfg)
if _, isNull := client.(NullAdapter); isNull {
return map[string]any{"adapter": "NullAdapter", "base_url": nil, "token": nil}, nil
}
a := client.(*GiteaForgejoAdapter)
return map[string]any{"adapter": "GiteaForgejoAdapter", "base_url": a.BaseURL, "token": a.Token}, nil
}
func execNull(in map[string]any) (any, error) {
method, _ := in["method"].(string)
args, _ := in["args"].([]any)
client := NullAdapter{}
switch method {
case "create_issue":
return client.CreateIssue(argStr(args, 0), argStr(args, 1))
case "add_comment":
return client.AddComment(numInt(args[0]), argStr(args, 1))
case "get_issue_state":
return client.GetIssueState(numInt(args[0]))
case "list_open_issues":
return client.ListOpenIssues()
case "search_issues":
return client.SearchIssues(argStr(args, 0))
}
return nil, fmt.Errorf("null: unknown method %s", method)
}
func argStr(args []any, i int) string {
if i < len(args) {
s, _ := args[i].(string)
return s
}
return ""
}
func newAdapterFrom(in map[string]any, rec *recorder) (*GiteaForgejoAdapter, *scriptedSender, error) {
method, _ := in["method"].(string)
url, _ := in["url"].(string)
token, _ := in["token"].(string)
owner, _ := in["owner"].(string)
repo, _ := in["repo"].(string)
entries, err := decodeScript(in["script"])
if err != nil {
return nil, nil, err
}
sender := &scriptedSender{entries: entries, requests: &rec.requests}
a := NewGiteaForgejoAdapter(url, token, owner, repo)
a.Sender = sender
a.Sleep = rec.sleep
_ = method
return a, sender, nil
}
func decodeScript(v any) ([]scriptEntry, error) {
list, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("script not a list")
}
out := make([]scriptEntry, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("script entry not an object")
}
e := scriptEntry{}
if exc, ok := m["exc"].(string); ok {
e.Exc = exc
}
if status, ok := m["status"]; ok {
e.Status = numInt(status)
}
if payload, ok := m["payload"]; ok {
e.Payload = payload
}
out = append(out, e)
}
return out, nil
}
func execCall(in map[string]any) (any, error) {
method, _ := in["method"].(string)
rec := &recorder{}
a, _, err := newAdapterFrom(in, rec)
if err != nil {
return nil, err
}
kwargs, _ := in["kwargs"].(map[string]any)
var result any
switch method {
case "create_issue":
result, err = a.CreateIssue(kwStr(kwargs, "title"), kwStr(kwargs, "body"))
case "add_comment":
result, err = a.AddComment(numInt(kwargs["issue_number"]), kwStr(kwargs, "body"))
case "get_issue_state":
result, err = a.GetIssueState(numInt(kwargs["issue_number"]))
case "list_open_issues":
result, err = a.ListOpenIssues()
case "search_issues":
result, err = a.SearchIssues(kwStr(kwargs, "query"))
default:
return nil, fmt.Errorf("call: unknown method %s", method)
}
if err != nil {
rec.errKind = errorKind(err)
result = nil
}
return rec.got(result), nil
}
func kwStr(m map[string]any, key string) string {
if v, ok := m[key]; ok {
s, _ := v.(string)
return s
}
return ""
}
func execRequest(in map[string]any) (any, error) {
rec := &recorder{}
method, _ := in["method"].(string)
rawURL, _ := in["url"].(string)
entries, err := decodeScript(in["script"])
if err != nil {
return nil, err
}
sender := &scriptedSender{entries: entries, requests: &rec.requests}
opts := RequestOptions{
Headers: toStringMap(in["headers"]),
JSON: in["json"],
Timeout: secondsDuration(in["timeout"]),
Retries: numInt(in["retries"]),
Backoff: secondsDuration(in["backoff"]),
Sleep: rec.sleep,
}
resp, err := Request(sender, method, rawURL, opts)
var result any
if err != nil {
rec.errKind = errorKind(err)
} else {
result, err = resp.JSON()
if err != nil {
return nil, err
}
}
return rec.got(result), nil
}
func toStringMap(v any) map[string]string {
m, ok := v.(map[string]any)
if !ok {
return nil
}
out := make(map[string]string, len(m))
for k, val := range m {
s, _ := val.(string)
out[k] = s
}
return out
}
func secondsDuration(v any) time.Duration {
if v == nil {
return 0
}
return time.Duration(numFloat(v) * float64(time.Second))
}
// ── canonical comparison (sorted-key JSON) ──────────────────────────────────
func canonicalFromRaw(raw []byte) (string, error) {
var v any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return "", err
}
return canonical(normalize(v)), nil
}
// canonical — sorted-key deterministic rendering of a decoded JSON value.
func canonical(v any) string {
switch t := v.(type) {
case nil:
return "null"
case bool:
if t {
return "true"
}
return "false"
case string:
b, _ := json.Marshal(t)
return string(b)
case float64:
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case json.Number:
f, _ := t.Float64()
return canonical(f)
case []any:
parts := make([]string, len(t))
for i, item := range t {
parts[i] = canonical(normalize(item))
}
return "[" + strings.Join(parts, ", ") + "]"
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
bk, _ := json.Marshal(k)
parts = append(parts, string(bk)+": "+canonical(normalize(t[k])))
}
return "{" + strings.Join(parts, ", ") + "}"
default:
return fmt.Sprintf("«%v»", t)
}
}
// normalize — flatten trees into plain map/float64 representations so
// canonical() sees one shape.
func normalize(v any) any {
switch t := v.(type) {
case map[string]any:
m := map[string]any{}
for k, val := range t {
m[k] = normalize(val)
}
return m
case []any:
out := make([]any, len(t))
for i, item := range t {
out[i] = normalize(item)
}
return out
case json.Number:
f, _ := t.Float64()
return f
default:
return v
}
}
func mustJSON(v any) string {
b, err := json.Marshal(normalize(v))
if err != nil {
return "null"
}
return string(b)
}
File diff suppressed because it is too large. Load diff
+285
View File
@@ -0,0 +1,285 @@
package main
// Battery — the HARD parity gate plus per-function batteries replaying the
// frozen oracle truth (truth.json, generated by
// scripts/gen_forge_client_truth.py against the LIVE Python oracle with a
// scripted httpx transport and a recording sleep) VERBATIM. Any divergence
// fails; the truth file rides the repo so the gate holds in CI without a
// live forge.
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
)
func loadTruthOrDie(t *testing.T) *TruthDoc {
t.Helper()
doc, err := loadTruth("truth.json")
if err != nil {
t.Fatalf("truth.json unusable (frozen oracle battery): %v", err)
}
return doc
}
func findRow(t *testing.T, name string) TruthRow {
t.Helper()
doc := loadTruthOrDie(t)
for _, row := range doc.Rows {
if row.Name == name {
return row
}
}
t.Fatalf("frozen row %q missing from truth.json — battery/truth drift", name)
return TruthRow{}
}
func mustReplay(t *testing.T, name string) {
t.Helper()
doc := loadTruthOrDie(t)
row := findRow(t, name)
if msg := compareOne(doc, &row); msg != "" {
t.Fatalf("DIVERGED %s: %s", name, msg)
}
}
func replayOp(t *testing.T, op string) int {
t.Helper()
doc := loadTruthOrDie(t)
n := 0
for i := range doc.Rows {
row := &doc.Rows[i]
if row.Op != op {
continue
}
n++
if msg := compareOne(doc, row); msg != "" {
t.Fatalf("DIVERGED %s: %s", row.Name, msg)
}
}
if n == 0 {
t.Fatalf("no %s rows in truth.json", op)
}
return n
}
// rowWant decodes a row's want JSON into a generic map.
func rowWant(t *testing.T, name string) map[string]any {
t.Helper()
row := findRow(t, name)
var want map[string]any
if err := json.Unmarshal(row.Want, &want); err != nil {
t.Fatalf("decode want of %s: %v", name, err)
}
return want
}
// TestTruthParity — the whole frozen battery, one shot.
func TestTruthParity(t *testing.T) {
doc := loadTruthOrDie(t)
var fails []string
for i := range doc.Rows {
if msg := compareOne(doc, &doc.Rows[i]); msg != "" {
fails = append(fails, doc.Rows[i].Name+": "+msg)
}
}
if len(fails) > 0 {
t.Fatalf("parity replay diverged after %d clean rows:\n%s",
len(doc.Rows)-len(fails), strings.Join(fails, "\n"))
}
t.Logf("PARITY OK: %d cases exact-equal vs the Python oracle", len(doc.Rows))
}
// TestBatteryCreateForgeClient — every selection branch: none, env, config
// keys, config-beats-env, owner/repo defaults and env overrides, trailing
// slash strip, empty-string falsy fallthrough, missing halves.
func TestBatteryCreateForgeClient(t *testing.T) {
if n := replayOp(t, "select"); n != 11 {
t.Fatalf("expected 11 frozen select rows, got %d — battery/truth drift", n)
}
want := rowWant(t, "select:none")
if want["adapter"] != "NullAdapter" {
t.Fatalf("unconfigured must be NullAdapter, got %v", want["adapter"])
}
want = rowWant(t, "select:env-configured")
if want["base_url"] != "https://forge.example.com/api/v1/repos/owner/repo" {
t.Fatalf("frozen env base_url changed: %v", want["base_url"])
}
want = rowWant(t, "select:config-beats-env")
if want["base_url"] != "https://cfg.example.com/api/v1/repos/owner/repo" ||
want["token"] != "cfg_tok" {
t.Fatalf("config-beats-env changed: %v", want)
}
want = rowWant(t, "select:trailing-slash-strip")
if want["base_url"] != "https://forge.example.com/api/v1/repos/owner/repo" {
t.Fatalf("trailing slash strip changed: %v", want["base_url"])
}
want = rowWant(t, "select:cfg-url-missing-token")
if want["adapter"] != "NullAdapter" {
t.Fatalf("url without token must stay dormant: %v", want["adapter"])
}
mustReplay(t, "select:owner-empty-env")
}
// TestBatteryNullAdapter — every no-op shape, zero errors.
func TestBatteryNullAdapter(t *testing.T) {
if n := replayOp(t, "null"); n != 5 {
t.Fatalf("expected 5 frozen null rows, got %d — battery/truth drift", n)
}
want := rowWant(t, "null:create-issue")
if want["created"] != false || want["reason"] != "no forge configured" {
t.Fatalf("frozen null create changed: %v", want)
}
want = rowWant(t, "null:get-issue-state")
if want["state"] != "unknown" {
t.Fatalf("frozen null state changed: %v", want)
}
}
// TestBatteryGiteaAdapter — every adapter method incl. pagination, the
// client-side marker filter, and retry propagation through _request.
func TestBatteryGiteaAdapter(t *testing.T) {
if n := replayOp(t, "call"); n != 14 {
t.Fatalf("expected 14 frozen call rows, got %d — battery/truth drift", n)
}
// create_issue posts to /issues with token auth and the JSON body.
want := rowWant(t, "call:create-issue")
reqs, _ := want["requests"].([]any)
if len(reqs) != 1 {
t.Fatalf("create-issue: expected 1 request, got %d", len(reqs))
}
req, _ := reqs[0].(map[string]any)
if !strings.HasSuffix(fmt.Sprint(req["url"]), "/api/v1/repos/o/r/issues") {
t.Fatalf("create-issue URL changed: %v", req["url"])
}
headers, _ := req["headers"].(map[string]any)
if headers["Authorization"] != "token tok" || headers["Content-Type"] != "application/json" {
t.Fatalf("frozen auth headers changed: %v", headers)
}
if fmt.Sprint(req["timeout"]) != "30" {
t.Fatalf("frozen timeout passthrough changed: %v", req["timeout"])
}
// search hits the repo-scoped LIST endpoint, never /issues/search.
want = rowWant(t, "call:search-single-page")
reqs, _ = want["requests"].([]any)
req, _ = reqs[0].(map[string]any)
searchURL := fmt.Sprint(req["url"])
if strings.Contains(searchURL, "/issues/search") {
t.Fatalf("regression: search used the 404ing /issues/search path")
}
if !strings.Contains(searchURL, "state=open") ||
!strings.Contains(searchURL, "type=issues") ||
!strings.Contains(searchURL, "q=%5Bauto-failure%3Acron%3AConnection%5D") {
t.Fatalf("frozen search query changed: %s", searchURL)
}
// pagination: a full 50-item page must be followed by page=2.
want = rowWant(t, "call:search-paginates")
reqs, _ = want["requests"].([]any)
if len(reqs) != 2 || !strings.Contains(fmt.Sprint(reqs[1].(map[string]any)["url"]), "page=2") {
t.Fatalf("frozen pagination changed: %d requests", len(reqs))
}
// retry through _request: connect-phase POST retried once, sleep 0.5s.
want = rowWant(t, "call:create-issue-retry-connect")
sleeps, _ := want["sleeps"].([]any)
if len(sleeps) != 1 || fmt.Sprint(sleeps[0]) != "0.5" {
t.Fatalf("frozen connect retry sleeps changed: %v", sleeps)
}
// 4xx is the information: one shot, no retry.
want = rowWant(t, "call:get-issue-state-4xx")
if want["error"] != "httpx.HTTPStatusError" {
t.Fatalf("frozen 4xx error kind changed: %v", want["error"])
}
reqs, _ = want["requests"].([]any)
if len(reqs) != 1 {
t.Fatalf("4xx must not retry: %d requests", len(reqs))
}
}
// TestBatteryRequest — every _request branch: noise retried, signal never,
// exponential backoff never before the first attempt, timeout passthrough,
// lowercase method routing.
func TestBatteryRequest(t *testing.T) {
if n := replayOp(t, "request"); n != 19 {
t.Fatalf("expected 19 frozen request rows, got %d — battery/truth drift", n)
}
// backoff is exponential: [0.5, 1.0], never before the first attempt.
want := rowWant(t, "request:backoff-exponential")
sleeps, _ := want["sleeps"].([]any)
if len(sleeps) != 2 || fmt.Sprint(sleeps[0]) != "0.5" || fmt.Sprint(sleeps[1]) != "1" {
t.Fatalf("frozen backoff schedule changed: %v", sleeps)
}
// connect exhaustion: retries+1 attempts, error propagates.
want = rowWant(t, "request:get-connect-exhaust")
if want["error"] != "httpx.ConnectError" {
t.Fatalf("frozen exhaust error changed: %v", want["error"])
}
reqs, _ := want["requests"].([]any)
if len(reqs) != 3 {
t.Fatalf("frozen attempt count changed: %d", len(reqs))
}
// POST read timeout: the server MAY have processed it — one shot.
want = rowWant(t, "request:post-read-timeout-no-retry")
if want["error"] != "httpx.ReadTimeout" {
t.Fatalf("frozen read-timeout kind changed: %v", want["error"])
}
reqs, _ = want["requests"].([]any)
if len(reqs) != 1 {
t.Fatalf("POST read timeout must not retry: %d requests", len(reqs))
}
// POST 5xx after send: ambiguous — one shot.
want = rowWant(t, "request:post-5xx-no-retry")
if want["error"] != "httpx.HTTPStatusError" {
t.Fatalf("frozen post-5xx kind changed: %v", want["error"])
}
// timeout passes through per call.
want = rowWant(t, "request:timeout-passthrough")
reqs, _ = want["requests"].([]any)
if fmt.Sprint(reqs[0].(map[string]any)["timeout"]) != "17" {
t.Fatalf("frozen timeout passthrough changed: %v", reqs[0])
}
mustReplay(t, "request:lowercase-method")
mustReplay(t, "request:headers-json-capture")
mustReplay(t, "request:get-5xx-exhaust")
mustReplay(t, "request:get-read-timeout-retries")
}
// TestBatteryRetryAsymmetry — direct cross-check (not via a truth row) of
// the behavior that motivated the module: GET 5xx is noise (retried to
// success), POST 5xx is signal (one shot, no double-create).
func TestBatteryRetryAsymmetry(t *testing.T) {
getRequests := &[]any{}
getSender := &scriptedSender{
entries: []scriptEntry{{Status: 502}, {Status: 200, Payload: map[string]any{"ok": 1}}},
requests: getRequests,
}
opts := DefaultRequestOptions()
opts.Sleep = func(time.Duration) {}
if _, err := Request(getSender, "GET", "https://x/api", opts); err != nil {
t.Fatalf("GET 5xx must be retried to success: %v", err)
}
if len(*getRequests) != 2 {
t.Fatalf("GET 5xx made %d requests — retry lost", len(*getRequests))
}
postRequests := &[]any{}
postSender := &scriptedSender{
entries: []scriptEntry{{Status: 502}, {Status: 200}},
requests: postRequests,
}
postOpts := DefaultRequestOptions()
postOpts.JSON = map[string]any{}
postOpts.Sleep = func(time.Duration) {}
if _, err := Request(postSender, "POST", "https://x/api", postOpts); err == nil {
t.Fatal("POST 5xx must never be retried")
}
if len(*postRequests) != 1 {
t.Fatalf("POST 5xx made %d requests — double-create hazard", len(*postRequests))
}
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}