encrypted reports into labeled GitHub issues. - GitHub REST client on net/http (host-testable via httptest; works under TinyGo js/wasm per #26). Encodes ADR #6 §3.2: serial mutations spaced >=1s, honour Retry-After, wait until x-ratelimit-reset, >=60s floor for secondary-limit 403s, full-jitter exponential backoff (base 1s, cap 60s, <=5 attempts). Ensures the three ADR #6 labels (create-or-ignore). - Publisher: GetPending -> crypto.Open -> format -> CreateIssue per id, with the ADR #6 per-run cap (50) and 65,536-char body cap (truncate). Per-report failures are isolated and surfaced, never abort the batch. - onPublished(ctx, id) seam, called only after a confirmed 201, default no-op: #15 wires it to lifecycle.MarkPublished to complete cross-run de-dup. #14 does not implement the mark-published transition. - Issue body wraps report free-text in a length-adaptive code fence and metadata in inline code, neutralising Markdown/@mention injection. - Worker: swap schedule.LogPublisher for the real publisher in scheduled_wasm.go; read GITHUB_TOKEN (Secrets Store) + GITHUB_REPO (var); pre-gate so the sibling cron fire does no secret I/O. worker/main.go untouched. Export storage.GetSecret for the token read. - wrangler.jsonc: add GITHUB_TOKEN secret + GITHUB_REPO var (my keys only). Tests (host, httptest mock, virtual clock): N reports -> N labeled issues + onPublished per success; >65,536-char body truncated; transient 5xx and Retry-After retried per policy; persistent failure isolated (no onPublished); permission 403 not retried; per-run cap; decrypt failure isolated. go vet + go test ./... green; GOOS=js GOARCH=wasm build compiles. Closes #14 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
368 lines
11 KiB
Go
368 lines
11 KiB
Go
package publish
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- test doubles: a virtual clock and a scriptable GitHub API mock ---
|
|
|
|
// fakeClock is a virtual clock whose time only advances when Sleep is called, so
|
|
// backoff/spacing are exercised with zero real delay and are assertable.
|
|
type fakeClock struct {
|
|
mu sync.Mutex
|
|
t time.Time
|
|
sleeps []time.Duration
|
|
}
|
|
|
|
func newFakeClock() *fakeClock {
|
|
return &fakeClock{t: time.Unix(1_700_000_000, 0).UTC()}
|
|
}
|
|
|
|
func (f *fakeClock) now() time.Time {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.t
|
|
}
|
|
|
|
func (f *fakeClock) sleep(_ context.Context, d time.Duration) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if d > 0 {
|
|
f.t = f.t.Add(d)
|
|
f.sleeps = append(f.sleeps, d)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeClock) sleepCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.sleeps)
|
|
}
|
|
|
|
func (f *fakeClock) lastSleep() time.Duration {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if len(f.sleeps) == 0 {
|
|
return 0
|
|
}
|
|
return f.sleeps[len(f.sleeps)-1]
|
|
}
|
|
|
|
// scriptedResp is one canned HTTP response for the mock.
|
|
type scriptedResp struct {
|
|
status int
|
|
header map[string]string
|
|
body string
|
|
}
|
|
|
|
// ghServer is a scriptable mock of the GitHub REST API for POST /labels and
|
|
// POST /issues. issueScript/labelScript are consumed front-to-back; when empty a
|
|
// default 201 is returned (and, for issues, a synthetic created-issue body).
|
|
type ghServer struct {
|
|
mu sync.Mutex
|
|
labelReqs []labelRequest
|
|
createdIssues []issueRequest // recorded only on a 201 response
|
|
issueAttempts int
|
|
labelAttempts int
|
|
issueScript []scriptedResp
|
|
labelScript []scriptedResp
|
|
nextIssueNum int
|
|
authHeaders []string
|
|
uaHeaders []string
|
|
apiVersions []string
|
|
}
|
|
|
|
func (g *ghServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
g.authHeaders = append(g.authHeaders, r.Header.Get("Authorization"))
|
|
g.uaHeaders = append(g.uaHeaders, r.Header.Get("User-Agent"))
|
|
g.apiVersions = append(g.apiVersions, r.Header.Get("X-GitHub-Api-Version"))
|
|
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/labels") && r.Method == http.MethodPost:
|
|
var lr labelRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&lr)
|
|
g.labelReqs = append(g.labelReqs, lr)
|
|
g.labelAttempts++
|
|
resp := popScript(&g.labelScript, scriptedResp{status: http.StatusCreated, body: `{}`})
|
|
writeResp(w, resp)
|
|
|
|
case strings.HasSuffix(r.URL.Path, "/issues") && r.Method == http.MethodPost:
|
|
var ir issueRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&ir)
|
|
g.issueAttempts++
|
|
resp := popScript(&g.issueScript, scriptedResp{})
|
|
if resp.status == 0 { // default: synthesize a success
|
|
g.nextIssueNum++
|
|
n := g.nextIssueNum
|
|
resp = scriptedResp{
|
|
status: http.StatusCreated,
|
|
body: fmt.Sprintf(`{"number":%d,"html_url":"https://github.com/o/r/issues/%d"}`, n, n),
|
|
}
|
|
}
|
|
if resp.status == http.StatusCreated {
|
|
g.createdIssues = append(g.createdIssues, ir)
|
|
}
|
|
writeResp(w, resp)
|
|
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func popScript(q *[]scriptedResp, def scriptedResp) scriptedResp {
|
|
if len(*q) == 0 {
|
|
return def
|
|
}
|
|
r := (*q)[0]
|
|
*q = (*q)[1:]
|
|
return r
|
|
}
|
|
|
|
func writeResp(w http.ResponseWriter, r scriptedResp) {
|
|
for k, v := range r.header {
|
|
w.Header().Set(k, v)
|
|
}
|
|
w.WriteHeader(r.status)
|
|
_, _ = w.Write([]byte(r.body))
|
|
}
|
|
|
|
// testClient builds a Client aimed at srvURL with a virtual clock and max-jitter
|
|
// (randf==1) so exponential backoff is the full computed value, deterministically.
|
|
func testClient(srvURL string, fc *fakeClock, opts ...ClientOption) *Client {
|
|
base := []ClientOption{
|
|
WithBaseURL(srvURL),
|
|
WithClock(fc.now, fc.sleep),
|
|
WithRand(func() float64 { return 1.0 }),
|
|
}
|
|
return NewClient("test-token", "o", "r", append(base, opts...)...)
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestCreateIssueSuccess(t *testing.T) {
|
|
g := &ghServer{}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, newFakeClock())
|
|
|
|
issue, err := c.CreateIssue(context.Background(), "the title", "the body", []string{"bug-report", "automated"})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
if issue.Number != 1 || !strings.Contains(issue.HTMLURL, "/issues/1") {
|
|
t.Errorf("unexpected issue: %+v", issue)
|
|
}
|
|
if len(g.createdIssues) != 1 {
|
|
t.Fatalf("server recorded %d issues, want 1", len(g.createdIssues))
|
|
}
|
|
got := g.createdIssues[0]
|
|
if got.Title != "the title" || got.Body != "the body" {
|
|
t.Errorf("issue payload = %+v", got)
|
|
}
|
|
if len(got.Labels) != 2 || got.Labels[0] != "bug-report" {
|
|
t.Errorf("labels = %v", got.Labels)
|
|
}
|
|
// Required GitHub headers.
|
|
if g.authHeaders[0] != "Bearer test-token" {
|
|
t.Errorf("auth header = %q", g.authHeaders[0])
|
|
}
|
|
if g.uaHeaders[0] == "" {
|
|
t.Error("missing User-Agent")
|
|
}
|
|
if g.apiVersions[0] != apiVersion {
|
|
t.Errorf("api version = %q, want %q", g.apiVersions[0], apiVersion)
|
|
}
|
|
}
|
|
|
|
func TestEnsureLabelsCreateOrIgnore(t *testing.T) {
|
|
g := &ghServer{
|
|
// First label already exists (422), the rest are created (201, the default).
|
|
labelScript: []scriptedResp{{status: http.StatusUnprocessableEntity, body: `{"message":"Validation Failed"}`}},
|
|
}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, newFakeClock())
|
|
|
|
if err := c.EnsureLabels(context.Background(), DefaultLabels); err != nil {
|
|
t.Fatalf("EnsureLabels: %v", err)
|
|
}
|
|
if g.labelAttempts != len(DefaultLabels) {
|
|
t.Fatalf("label POSTs = %d, want %d", g.labelAttempts, len(DefaultLabels))
|
|
}
|
|
// Names and colours must match the ADR contract.
|
|
names := map[string]string{}
|
|
for _, l := range g.labelReqs {
|
|
names[l.Name] = l.Color
|
|
}
|
|
for _, want := range DefaultLabels {
|
|
if names[want.Name] != want.Color {
|
|
t.Errorf("label %q colour = %q, want %q", want.Name, names[want.Name], want.Color)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRetryOn5xxThenSucceeds(t *testing.T) {
|
|
fc := newFakeClock()
|
|
g := &ghServer{issueScript: []scriptedResp{{status: http.StatusInternalServerError, body: "boom"}}}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, fc)
|
|
|
|
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
|
|
t.Fatalf("CreateIssue should recover after a transient 500: %v", err)
|
|
}
|
|
if g.issueAttempts != 2 {
|
|
t.Errorf("issue attempts = %d, want 2 (one 500, one success)", g.issueAttempts)
|
|
}
|
|
// One backoff sleep of the full attempt-0 backoff (1s, randf==1).
|
|
if fc.sleepCount() != 1 || fc.lastSleep() != 1*time.Second {
|
|
t.Errorf("backoff sleeps = %v (count %d), want a single 1s sleep", fc.sleeps, fc.sleepCount())
|
|
}
|
|
}
|
|
|
|
func TestRetryAfterHonoredExactly(t *testing.T) {
|
|
fc := newFakeClock()
|
|
g := &ghServer{issueScript: []scriptedResp{{
|
|
status: http.StatusTooManyRequests,
|
|
header: map[string]string{"Retry-After": "3"},
|
|
body: "slow down",
|
|
}}}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, fc)
|
|
|
|
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
// Retry-After overrides computed backoff: exactly 3s, not jittered.
|
|
if fc.lastSleep() != 3*time.Second {
|
|
t.Errorf("waited %v, want exactly 3s (Retry-After honoured)", fc.lastSleep())
|
|
}
|
|
}
|
|
|
|
func TestRateLimitResetHonored(t *testing.T) {
|
|
fc := newFakeClock()
|
|
reset := fc.now().Add(30 * time.Second).Unix()
|
|
g := &ghServer{issueScript: []scriptedResp{{
|
|
status: http.StatusForbidden,
|
|
header: map[string]string{
|
|
"X-RateLimit-Remaining": "0",
|
|
"X-RateLimit-Reset": fmt.Sprintf("%d", reset),
|
|
},
|
|
body: "rate limited",
|
|
}}}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, fc)
|
|
|
|
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
// Waits until reset (30s) plus the 1s wake buffer.
|
|
if fc.lastSleep() != 31*time.Second {
|
|
t.Errorf("waited %v, want 31s (until x-ratelimit-reset + 1s)", fc.lastSleep())
|
|
}
|
|
}
|
|
|
|
func TestSecondaryRateLimitFloor(t *testing.T) {
|
|
fc := newFakeClock()
|
|
// A secondary-rate-limit 403 with no Retry-After: floor of 60s applies even
|
|
// though the attempt-0 exponential backoff would be only ~1s.
|
|
g := &ghServer{issueScript: []scriptedResp{{
|
|
status: http.StatusForbidden,
|
|
body: `{"message":"You have exceeded a secondary rate limit"}`,
|
|
}}}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, fc)
|
|
|
|
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
if fc.lastSleep() < 60*time.Second {
|
|
t.Errorf("secondary-limit wait = %v, want ≥60s", fc.lastSleep())
|
|
}
|
|
}
|
|
|
|
func TestPermission403NotRetried(t *testing.T) {
|
|
fc := newFakeClock()
|
|
// A plain 403 with no rate-limit signals is a permission error: surface it
|
|
// immediately, do not retry.
|
|
g := &ghServer{issueScript: []scriptedResp{{
|
|
status: http.StatusForbidden,
|
|
body: `{"message":"Resource not accessible by personal access token"}`,
|
|
}}}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, fc)
|
|
|
|
_, err := c.CreateIssue(context.Background(), "t", "b", nil)
|
|
if err == nil {
|
|
t.Fatal("expected an error for a permission 403")
|
|
}
|
|
if g.issueAttempts != 1 {
|
|
t.Errorf("issue attempts = %d, want 1 (permission 403 must not retry)", g.issueAttempts)
|
|
}
|
|
if fc.sleepCount() != 0 {
|
|
t.Errorf("slept %d times on a non-retryable 403, want 0", fc.sleepCount())
|
|
}
|
|
}
|
|
|
|
func TestMaxAttemptsExhausted(t *testing.T) {
|
|
fc := newFakeClock()
|
|
// Every attempt fails with 500; with maxAttempts=3 the client gives up after 3.
|
|
g := &ghServer{issueScript: []scriptedResp{
|
|
{status: http.StatusBadGateway, body: "1"},
|
|
{status: http.StatusBadGateway, body: "2"},
|
|
{status: http.StatusBadGateway, body: "3"},
|
|
{status: http.StatusBadGateway, body: "4"},
|
|
}}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, fc, WithMaxAttempts(3))
|
|
|
|
_, err := c.CreateIssue(context.Background(), "t", "b", nil)
|
|
if err == nil {
|
|
t.Fatal("expected an error after exhausting attempts")
|
|
}
|
|
if g.issueAttempts != 3 {
|
|
t.Errorf("issue attempts = %d, want 3", g.issueAttempts)
|
|
}
|
|
// Two backoffs between three attempts.
|
|
if fc.sleepCount() != 2 {
|
|
t.Errorf("backoff sleeps = %d, want 2", fc.sleepCount())
|
|
}
|
|
}
|
|
|
|
func TestSpacingBetweenMutations(t *testing.T) {
|
|
fc := newFakeClock()
|
|
g := &ghServer{}
|
|
srv := httptest.NewServer(g)
|
|
defer srv.Close()
|
|
c := testClient(srv.URL, fc)
|
|
|
|
// Two consecutive creates with no time passing between them: the second must
|
|
// be spaced by ≥1s (ADR #6).
|
|
if _, err := c.CreateIssue(context.Background(), "a", "b", nil); err != nil {
|
|
t.Fatalf("first create: %v", err)
|
|
}
|
|
if _, err := c.CreateIssue(context.Background(), "c", "d", nil); err != nil {
|
|
t.Fatalf("second create: %v", err)
|
|
}
|
|
if fc.sleepCount() != 1 || fc.lastSleep() != 1*time.Second {
|
|
t.Errorf("spacing sleeps = %v, want a single 1s spacing before the 2nd create", fc.sleeps)
|
|
}
|
|
}
|