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>
494 lines
17 KiB
Go
494 lines
17 KiB
Go
// Package publish turns pending, encrypted bug-reports into GitHub issues on the
|
|
// LibreMail app repo — the "publish" half of the weekly job (#14). It is handed a
|
|
// batch of pending report ids by internal/schedule (the Friday-17:00-Central
|
|
// gate, #13) and, for each id, fetches the still-encrypted frame
|
|
// (lifecycle.GetPending, #10), decrypts it (internal/crypto, #5), formats it into
|
|
// a well-formed issue body, and creates a labeled issue via the GitHub REST API.
|
|
//
|
|
// # Host-testable, Wasm-deployable
|
|
//
|
|
// The package carries no build constraints. The GitHub client is built on the
|
|
// standard net/http (the #26 patch makes net/http's client work under TinyGo's
|
|
// js/wasm target, routing through the Cloudflare fetch runtime), so the exact
|
|
// same code is exercised by `go test` on the host against an httptest mock and
|
|
// runs in the deployed Worker. All wall-clock behaviour (rate-limit spacing,
|
|
// backoff sleeps, the ratelimit-reset clock) is injected, so the retry policy is
|
|
// unit-tested deterministically with no real sleeps.
|
|
//
|
|
// # GitHub limits (ADR #6, docs/decisions/labels-and-abuse.md)
|
|
//
|
|
// The client encodes ADR #6 §3.2 exactly: serial mutations spaced by ≥1s, honour
|
|
// Retry-After, wait until x-ratelimit-reset when remaining is 0, a ≥60s floor for
|
|
// secondary-rate-limit 403s, and full-jittered exponential backoff (base 1s, cap
|
|
// 60s, ≤5 attempts) on 5xx/network errors. Issues carry the three ADR #6 labels
|
|
// (bug-report, automated, needs-triage), created if missing. The publish job
|
|
// applies the ADR #6 §3.1 per-run cap (50) and the 65,536-char body cap.
|
|
package publish
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// GitHub API defaults.
|
|
const (
|
|
// defaultBaseURL is the public GitHub REST API root. Overridable with
|
|
// WithBaseURL so tests can point the client at an httptest server.
|
|
defaultBaseURL = "https://api.github.com"
|
|
// apiVersion is sent as X-GitHub-Api-Version, pinning the REST API contract.
|
|
apiVersion = "2022-11-28"
|
|
// userAgent identifies this job to GitHub (a User-Agent header is required).
|
|
userAgent = "LibreMail-Bug-Report-Ingest"
|
|
)
|
|
|
|
// Retry/pacing defaults, from ADR #6 §3.2.
|
|
const (
|
|
// defaultMinSpacing is the minimum wall-clock gap between mutating requests
|
|
// (issue/label creation). ADR #6: "space successful creates by ≥1 second".
|
|
defaultMinSpacing = 1 * time.Second
|
|
// defaultBaseBackoff and defaultCapBackoff bound the exponential schedule
|
|
// min(60s, 1s·2^attempt).
|
|
defaultBaseBackoff = 1 * time.Second
|
|
defaultCapBackoff = 60 * time.Second
|
|
// defaultSecondaryMin is the ≥60s floor for a secondary-rate-limit 403 that
|
|
// arrives without a Retry-After header.
|
|
defaultSecondaryMin = 60 * time.Second
|
|
// defaultMaxAttempts is the per-request attempt cap (ADR #6: "max 5 attempts
|
|
// per issue"): the initial try plus up to four backoff retries.
|
|
defaultMaxAttempts = 5
|
|
)
|
|
|
|
// Client is a minimal GitHub REST API client scoped to one owner/repo. It creates
|
|
// labels and issues and applies ADR #6's pacing, backoff, and rate-limit policy.
|
|
// It is safe for the serial, single-goroutine use the scheduled publish job makes
|
|
// of it; it does not add its own concurrency.
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
baseURL string
|
|
token string
|
|
owner string
|
|
repo string
|
|
|
|
minSpacing time.Duration
|
|
baseBackoff time.Duration
|
|
capBackoff time.Duration
|
|
secondaryMin time.Duration
|
|
maxAttempts int
|
|
|
|
// Injected wall-clock + randomness seams, so backoff/spacing are deterministic
|
|
// and instant under test. Defaults use real time and math/rand.
|
|
now func() time.Time
|
|
sleep func(context.Context, time.Duration) error
|
|
randf func() float64
|
|
|
|
mu sync.Mutex
|
|
lastMutation time.Time // time of the last mutating request (for spacing)
|
|
}
|
|
|
|
// ClientOption customises a Client. The wall-clock/rand options exist for tests.
|
|
type ClientOption func(*Client)
|
|
|
|
// WithBaseURL overrides the GitHub API root (tests point it at httptest).
|
|
func WithBaseURL(u string) ClientOption {
|
|
return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") }
|
|
}
|
|
|
|
// WithHTTPClient overrides the underlying *http.Client.
|
|
func WithHTTPClient(h *http.Client) ClientOption {
|
|
return func(c *Client) { c.httpClient = h }
|
|
}
|
|
|
|
// WithClock injects the time source and sleep function (tests use a virtual
|
|
// clock so backoff/spacing incur no real delay). Both must be set together.
|
|
func WithClock(now func() time.Time, sleep func(context.Context, time.Duration) error) ClientOption {
|
|
return func(c *Client) {
|
|
if now != nil {
|
|
c.now = now
|
|
}
|
|
if sleep != nil {
|
|
c.sleep = sleep
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithRand injects the [0,1) source used for full-jitter backoff (tests pin it
|
|
// to make jittered sleeps deterministic).
|
|
func WithRand(randf func() float64) ClientOption {
|
|
return func(c *Client) {
|
|
if randf != nil {
|
|
c.randf = randf
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithMaxAttempts overrides the per-request attempt cap (default 5).
|
|
func WithMaxAttempts(n int) ClientOption {
|
|
return func(c *Client) {
|
|
if n > 0 {
|
|
c.maxAttempts = n
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithSpacing overrides the minimum gap between mutating requests (default 1s).
|
|
func WithSpacing(d time.Duration) ClientOption {
|
|
return func(c *Client) { c.minSpacing = d }
|
|
}
|
|
|
|
// NewClient returns a Client for github.com/<owner>/<repo> authenticated with
|
|
// token (a Worker secret; never log it). Defaults encode ADR #6 §3.2; override
|
|
// via options (mainly in tests).
|
|
func NewClient(token, owner, repo string, opts ...ClientOption) *Client {
|
|
src := rand.New(rand.NewSource(time.Now().UnixNano())) // jitter only; not crypto
|
|
c := &Client{
|
|
httpClient: &http.Client{},
|
|
baseURL: defaultBaseURL,
|
|
token: token,
|
|
owner: owner,
|
|
repo: repo,
|
|
minSpacing: defaultMinSpacing,
|
|
baseBackoff: defaultBaseBackoff,
|
|
capBackoff: defaultCapBackoff,
|
|
secondaryMin: defaultSecondaryMin,
|
|
maxAttempts: defaultMaxAttempts,
|
|
now: time.Now,
|
|
sleep: sleepCtx,
|
|
randf: src.Float64,
|
|
}
|
|
for _, o := range opts {
|
|
o(c)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// sleepCtx sleeps for d, returning early if ctx is cancelled. It is the default
|
|
// Client sleep; tests swap in a virtual-clock sleep that returns instantly.
|
|
func sleepCtx(ctx context.Context, d time.Duration) error {
|
|
if d <= 0 {
|
|
return ctx.Err()
|
|
}
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-t.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Label is a GitHub issue label to ensure-exists and apply. Colour is a 6-hex-
|
|
// digit string without the leading '#'.
|
|
type Label struct {
|
|
Name string
|
|
Color string
|
|
Description string
|
|
}
|
|
|
|
// DefaultLabels are the three ADR #6 §1 labels every auto-published issue carries.
|
|
// Names are the binding contract; colours are advisory (the maintainer may
|
|
// recolour). All three are applied at issue creation.
|
|
var DefaultLabels = []Label{
|
|
{Name: "bug-report", Color: "e99695", Description: "Originated from the app's opt-in debug bug-report ingest pipeline."},
|
|
{Name: "automated", Color: "c5def5", Description: "Created by the weekly publish job, not written by a human."},
|
|
{Name: "needs-triage", Color: "fbca04", Description: "Not yet reviewed or confirmed by the maintainer."},
|
|
}
|
|
|
|
// LabelNames returns just the label names, for the issue-creation payload.
|
|
func LabelNames(labels []Label) []string {
|
|
names := make([]string, len(labels))
|
|
for i, l := range labels {
|
|
names[i] = l.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
// CreatedIssue is the slice of GitHub's issue-creation response the job needs.
|
|
type CreatedIssue struct {
|
|
Number int `json:"number"`
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
|
|
// EnsureLabels creates each label that does not yet exist, idempotently (ADR #6:
|
|
// "create-or-ignore"). An already-existing label (HTTP 422) is treated as
|
|
// success. Errors for individual labels are joined; callers may treat a failure
|
|
// as best-effort (issue creation still references the label names).
|
|
func (c *Client) EnsureLabels(ctx context.Context, labels []Label) error {
|
|
var errs []error
|
|
for _, l := range labels {
|
|
if err := c.ensureLabel(ctx, l); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// labelRequest / issueRequest are the JSON bodies for the two POST endpoints.
|
|
type labelRequest struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
type issueRequest struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Labels []string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (c *Client) ensureLabel(ctx context.Context, l Label) error {
|
|
body, err := json.Marshal(labelRequest{Name: l.Name, Color: l.Color, Description: l.Description})
|
|
if err != nil {
|
|
return fmt.Errorf("github: marshal label %q: %w", l.Name, err)
|
|
}
|
|
status, respBody, err := c.do(ctx, http.MethodPost, c.repoPath("labels"), body)
|
|
if err != nil {
|
|
return fmt.Errorf("github: create label %q: %w", l.Name, err)
|
|
}
|
|
switch status {
|
|
case http.StatusCreated:
|
|
return nil
|
|
case http.StatusUnprocessableEntity:
|
|
// 422 on label creation means the name already exists ("already_exists"):
|
|
// the idempotent create-or-ignore outcome ADR #6 asks for.
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("github: create label %q: unexpected status %d: %s", l.Name, status, snippet(respBody))
|
|
}
|
|
}
|
|
|
|
// CreateIssue creates one issue and returns it. Per ADR #6 a report is publishable
|
|
// only on a confirmed 201 Created, so a nil error from CreateIssue is exactly that
|
|
// confirmation — the caller marks the report published only then.
|
|
func (c *Client) CreateIssue(ctx context.Context, title, body string, labels []string) (CreatedIssue, error) {
|
|
reqBody, err := json.Marshal(issueRequest{Title: title, Body: body, Labels: labels})
|
|
if err != nil {
|
|
return CreatedIssue{}, fmt.Errorf("github: marshal issue: %w", err)
|
|
}
|
|
status, respBody, err := c.do(ctx, http.MethodPost, c.repoPath("issues"), reqBody)
|
|
if err != nil {
|
|
return CreatedIssue{}, fmt.Errorf("github: create issue: %w", err)
|
|
}
|
|
if status != http.StatusCreated {
|
|
return CreatedIssue{}, fmt.Errorf("github: create issue: unexpected status %d: %s", status, snippet(respBody))
|
|
}
|
|
var issue CreatedIssue
|
|
if err := json.Unmarshal(respBody, &issue); err != nil {
|
|
return CreatedIssue{}, fmt.Errorf("github: create issue: decode response: %w", err)
|
|
}
|
|
return issue, nil
|
|
}
|
|
|
|
// repoPath builds "/repos/<owner>/<repo>/<sub>".
|
|
func (c *Client) repoPath(sub string) string {
|
|
return "/repos/" + c.owner + "/" + c.repo + "/" + sub
|
|
}
|
|
|
|
// do performs a mutating request with ADR #6 pacing + retry. It returns the final
|
|
// HTTP status and body. A non-nil error means the request could not be completed
|
|
// within the attempt budget (a transport error, or an exhausted-retry status); a
|
|
// non-retryable HTTP status (2xx, or a 4xx like 401/404/422) returns that status
|
|
// with a nil error so the caller can interpret it.
|
|
func (c *Client) do(ctx context.Context, method, path string, reqBody []byte) (int, []byte, error) {
|
|
url := c.baseURL + path
|
|
var (
|
|
lastStatus int
|
|
lastBody []byte
|
|
lastErr error
|
|
)
|
|
for attempt := 0; attempt < c.maxAttempts; attempt++ {
|
|
// Serialize + space mutations (ADR #6): ensure ≥minSpacing since the last
|
|
// one before issuing this attempt.
|
|
if err := c.spaceMutations(ctx); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return 0, nil, err // request construction failure is not retryable
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
c.recordMutation()
|
|
|
|
var wait time.Duration
|
|
var retryable bool
|
|
if err != nil {
|
|
// Network/transport error: retry with jittered exponential backoff.
|
|
lastStatus, lastBody, lastErr = 0, nil, err
|
|
wait, retryable = c.jitteredBackoff(attempt), true
|
|
} else {
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
lastStatus, lastBody, lastErr = 0, nil, readErr
|
|
wait, retryable = c.jitteredBackoff(attempt), true
|
|
} else {
|
|
wait, retryable = c.retryDecision(resp, body, attempt)
|
|
if !retryable {
|
|
return resp.StatusCode, body, nil
|
|
}
|
|
lastStatus, lastBody = resp.StatusCode, body
|
|
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, snippet(body))
|
|
}
|
|
}
|
|
|
|
if attempt == c.maxAttempts-1 {
|
|
return lastStatus, lastBody, fmt.Errorf("github: %s %s: gave up after %d attempt(s): %w",
|
|
method, path, c.maxAttempts, lastErr)
|
|
}
|
|
if err := c.sleep(ctx, wait); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
}
|
|
// Unreachable (maxAttempts ≥ 1), but keeps the compiler happy.
|
|
return lastStatus, lastBody, lastErr
|
|
}
|
|
|
|
// setHeaders applies the standard GitHub REST headers. The token is a secret and
|
|
// must never be logged.
|
|
func (c *Client) setHeaders(req *http.Request) {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("X-GitHub-Api-Version", apiVersion)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", userAgent)
|
|
}
|
|
|
|
// retryDecision encodes ADR #6 §3.2's rate-limit/error policy: it reports how long
|
|
// to wait before the next attempt and whether the response is retryable at all.
|
|
//
|
|
// - 5xx: retryable, jittered exponential backoff.
|
|
// - 403/429 with Retry-After: retryable, honour it exactly.
|
|
// - 403/429 with x-ratelimit-remaining: 0: retryable, wait until x-ratelimit-reset.
|
|
// - 429 otherwise: retryable, jittered exponential backoff.
|
|
// - 403 secondary rate limit without Retry-After: retryable, ≥60s then exponential.
|
|
// - 403 without any rate-limit signal (a permission error): NOT retryable.
|
|
// - everything else (2xx/4xx): not retryable (the caller interprets the status).
|
|
func (c *Client) retryDecision(resp *http.Response, body []byte, attempt int) (time.Duration, bool) {
|
|
code := resp.StatusCode
|
|
if code >= 500 {
|
|
return c.jitteredBackoff(attempt), true
|
|
}
|
|
if code != http.StatusForbidden && code != http.StatusTooManyRequests {
|
|
return 0, false
|
|
}
|
|
|
|
if d, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
|
|
return d, true // honour Retry-After exactly (overrides computed backoff)
|
|
}
|
|
if resp.Header.Get("X-RateLimit-Remaining") == "0" {
|
|
if d, ok := c.untilReset(resp.Header.Get("X-RateLimit-Reset")); ok {
|
|
return d, true
|
|
}
|
|
return c.jitteredBackoff(attempt), true // remaining=0 but no usable reset
|
|
}
|
|
if code == http.StatusTooManyRequests {
|
|
return c.jitteredBackoff(attempt), true
|
|
}
|
|
// From here code == 403 with no Retry-After and remaining != 0.
|
|
if isSecondaryRateLimit(body) {
|
|
d := c.jitteredBackoff(attempt)
|
|
if d < c.secondaryMin {
|
|
d = c.secondaryMin // ADR #6: ≥60s floor for secondary limit
|
|
}
|
|
return d, true
|
|
}
|
|
// A plain 403 (bad token, no push access, ...) is permanent — do not retry.
|
|
return 0, false
|
|
}
|
|
|
|
// spaceMutations blocks until at least minSpacing has elapsed since the previous
|
|
// mutating request, implementing ADR #6's "≥1s between mutations".
|
|
func (c *Client) spaceMutations(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
last := c.lastMutation
|
|
c.mu.Unlock()
|
|
if last.IsZero() {
|
|
return nil
|
|
}
|
|
if elapsed := c.now().Sub(last); elapsed < c.minSpacing {
|
|
return c.sleep(ctx, c.minSpacing-elapsed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordMutation stamps the time of the just-issued mutating request.
|
|
func (c *Client) recordMutation() {
|
|
c.mu.Lock()
|
|
c.lastMutation = c.now()
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// jitteredBackoff returns a full-jitter delay in [0, min(cap, base·2^attempt)]
|
|
// (ADR #6: base 1s, cap 60s, full jitter).
|
|
func (c *Client) jitteredBackoff(attempt int) time.Duration {
|
|
backoff := c.baseBackoff << attempt // base · 2^attempt
|
|
if backoff <= 0 || backoff > c.capBackoff {
|
|
backoff = c.capBackoff
|
|
}
|
|
return time.Duration(c.randf() * float64(backoff))
|
|
}
|
|
|
|
// untilReset parses an x-ratelimit-reset epoch-seconds header and returns the
|
|
// (clamped, +1s buffer) wait until that instant per the injected clock.
|
|
func (c *Client) untilReset(reset string) (time.Duration, bool) {
|
|
if reset == "" {
|
|
return 0, false
|
|
}
|
|
epoch, err := strconv.ParseInt(strings.TrimSpace(reset), 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
d := time.Unix(epoch, 0).Sub(c.now())
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
return d + time.Second, true // small buffer so we wake just after reset
|
|
}
|
|
|
|
// parseRetryAfter parses a Retry-After header expressed as integer seconds (the
|
|
// form GitHub sends). Returns false if absent or unparseable.
|
|
func parseRetryAfter(v string) (time.Duration, bool) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return 0, false
|
|
}
|
|
secs, err := strconv.Atoi(v)
|
|
if err != nil || secs < 0 {
|
|
return 0, false
|
|
}
|
|
return time.Duration(secs) * time.Second, true
|
|
}
|
|
|
|
// isSecondaryRateLimit reports whether a 403 body is GitHub's secondary-rate-limit
|
|
// message (as opposed to a permission error), so only the former is retried.
|
|
func isSecondaryRateLimit(body []byte) bool {
|
|
lower := bytes.ToLower(body)
|
|
return bytes.Contains(lower, []byte("secondary rate limit")) ||
|
|
bytes.Contains(lower, []byte("abuse detection"))
|
|
}
|
|
|
|
// snippet returns a short, single-line excerpt of a response body for error
|
|
// messages (bounded so a huge body cannot bloat a log line).
|
|
func snippet(body []byte) string {
|
|
const max = 200
|
|
s := strings.TrimSpace(string(body))
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
if len(s) > max {
|
|
return s[:max] + "…"
|
|
}
|
|
return s
|
|
}
|