Adds internal/publish: the real schedule.Publisher (#13's seam) that turns each pending, encrypted bug-report into a labeled GitHub issue on the target repo, and swaps it in for the no-op LogPublisher in the Worker.
Publish flow (per pending id, oldest-first, capped at 50)
lifecycle.GetPending(id) -> crypto.Open (decrypt with the Secrets-Store keyring) -> format into a well-formed Markdown issue body -> create the issue via the GitHub REST API with the three ADR #6 labels (bug-report, automated, needs-triage, created if missing) -> call the onPublished(ctx, id) hook. Per-report failures (fetch/decrypt/create/hook) are isolated and surfaced, never aborting the batch.
Built on the standard net/http (host-testable with httptest; works under TinyGo js/wasm per #26 — no custom transport). All wall-clock behaviour is injected, so the retry policy is unit-tested with a virtual clock and zero real sleeps. Encodes ADR #6 §3.2 exactly:
Serial mutations spaced by >=1s; per-run cap of 50 (§3.1); 65,536-char body cap (truncate).
Honour Retry-After exactly; wait until x-ratelimit-reset when x-ratelimit-remaining: 0; >=60s floor for a secondary-rate-limit 403 without Retry-After; full-jitter exponential backoff min(60s, 1s*2^attempt), <=5 attempts, on 5xx/network errors.
A plain permission 403 (no rate-limit signal) is not retried — surfaced immediately.
Labels ensured via idempotent create-or-ignore (422 == already exists).
Issue formatting wraps the (scrubbed but untrusted) report free-text in a length-adaptive code fence and metadata in inline code, neutralising Markdown/@mention injection; oversize bodies are truncated with a marker.
WithOnPublished(func(ctx, id) error) — default no-op — is invoked only after a confirmed 201 Created (ADR #6 "mark published only on confirmed success"). #15 wires it to lifecycle.MarkPublished (+ partial-failure orchestration) to complete cross-run de-dup: a marked report leaves the pending set so a later run never re-publishes it. This PR deliberately does not implement the mark-published transition — it leaves that seam clean and documented.
Worker wiring
worker/scheduled_wasm.go: swaps schedule.LogPublisher for the real publisher; reads GITHUB_TOKEN (Secrets Store, async get() like the keyring) and GITHUB_REPO (plain var, default JMR-dev/LibreMail); pre-checks the Friday-17:00-Central gate so the sibling cron fire does no secret I/O. schedule.Run re-applies the gate authoritatively.
worker/main.go is untouched.
Exported storage.GetSecret (thin wrapper over the existing async secret read) so the token is read without duplicating plumbing.
wrangler.jsonc: added the GITHUB_TOKEN Secrets-Store secret and the GITHUB_REPO var (only my keys; other sections untouched).
Test evidence (host, no TinyGo)
go vet ./... clean, go test ./... green, GOOS=js GOARCH=wasm go build ./... compiles. New internal/publish tests (mock GitHub API via httptest, virtual clock):
N pending encrypted reports + a test keyring -> exactly N well-formed labeled issues, onPublished called once per success (also verified end-to-end through the real lifecycle.Manager).
Formatting: well-formed body; a >65,536-char body is truncated to fit; Markdown-injection safety; unparseable payload shown verbatim.
Backoff/retry: transient 5xx and Retry-After retried per policy; x-ratelimit-reset honoured; secondary-limit floor; permission 403 not retried; attempt exhaustion.
Isolation: a persistent create failure and a decrypt failure each isolate that one report (others still publish) and do not call onPublished.
Per-run cap (50) respected (oldest-first).
No new HTTP server endpoint, so no Bruno changes; existing tests/devserver/Bruno suite are unaffected.
Out-of-scope note
Pre-existing flaky test internal/crypto TestSealOpenRoundtrip: for a 1-byte plaintext it asserts the byte is absent from the ~36-byte frame, which fails ~11% of the time by chance (unrelated to this PR; passes on rerun). Left as-is.
## What this does
Adds `internal/publish`: the real `schedule.Publisher` (#13's seam) that turns each pending, encrypted bug-report into a labeled GitHub issue on the target repo, and swaps it in for the no-op `LogPublisher` in the Worker.
### Publish flow (per pending id, oldest-first, capped at 50)
`lifecycle.GetPending(id)` -> `crypto.Open` (decrypt with the Secrets-Store keyring) -> **format** into a well-formed Markdown issue body -> **create the issue** via the GitHub REST API with the three ADR #6 labels (`bug-report`, `automated`, `needs-triage`, created if missing) -> call the `onPublished(ctx, id)` hook. Per-report failures (fetch/decrypt/create/hook) are isolated and surfaced, never aborting the batch.
### GitHub client + ADR #6 limits (`internal/publish/github.go`)
Built on the standard `net/http` (host-testable with `httptest`; works under TinyGo js/wasm per #26 — no custom transport). All wall-clock behaviour is injected, so the retry policy is unit-tested with a virtual clock and zero real sleeps. Encodes ADR #6 §3.2 exactly:
- Serial mutations spaced by >=1s; per-run cap of 50 (§3.1); 65,536-char body cap (truncate).
- Honour `Retry-After` exactly; wait until `x-ratelimit-reset` when `x-ratelimit-remaining: 0`; >=60s floor for a secondary-rate-limit 403 without `Retry-After`; full-jitter exponential backoff `min(60s, 1s*2^attempt)`, <=5 attempts, on 5xx/network errors.
- A plain permission 403 (no rate-limit signal) is **not** retried — surfaced immediately.
- Labels ensured via idempotent create-or-ignore (422 == already exists).
Issue formatting wraps the (scrubbed but untrusted) report free-text in a length-adaptive code fence and metadata in inline code, neutralising Markdown/`@mention` injection; oversize bodies are truncated with a marker.
### The `onPublished` seam for #15
`WithOnPublished(func(ctx, id) error)` — default no-op — is invoked **only after a confirmed 201 Created** (ADR #6 "mark published only on confirmed success"). #15 wires it to `lifecycle.MarkPublished` (+ partial-failure orchestration) to complete **cross-run de-dup**: a marked report leaves the pending set so a later run never re-publishes it. This PR deliberately does **not** implement the mark-published transition — it leaves that seam clean and documented.
### Worker wiring
- `worker/scheduled_wasm.go`: swaps `schedule.LogPublisher` for the real publisher; reads `GITHUB_TOKEN` (Secrets Store, async `get()` like the keyring) and `GITHUB_REPO` (plain var, default `JMR-dev/LibreMail`); pre-checks the Friday-17:00-Central gate so the sibling cron fire does **no** secret I/O. `schedule.Run` re-applies the gate authoritatively.
- `worker/main.go` is **untouched**.
- Exported `storage.GetSecret` (thin wrapper over the existing async secret read) so the token is read without duplicating plumbing.
- `wrangler.jsonc`: added the `GITHUB_TOKEN` Secrets-Store secret and the `GITHUB_REPO` var (only my keys; other sections untouched).
## Test evidence (host, no TinyGo)
`go vet ./...` clean, `go test ./...` green, `GOOS=js GOARCH=wasm go build ./...` compiles. New `internal/publish` tests (mock GitHub API via `httptest`, virtual clock):
- N pending encrypted reports + a test keyring -> exactly N well-formed **labeled** issues, `onPublished` called once per success (also verified end-to-end through the real `lifecycle.Manager`).
- Formatting: well-formed body; a >65,536-char body is truncated to fit; Markdown-injection safety; unparseable payload shown verbatim.
- Backoff/retry: transient 5xx and `Retry-After` retried per policy; `x-ratelimit-reset` honoured; secondary-limit floor; permission 403 not retried; attempt exhaustion.
- Isolation: a persistent create failure and a decrypt failure each isolate that one report (others still publish) and do **not** call `onPublished`.
- Per-run cap (50) respected (oldest-first).
No new HTTP server endpoint, so no Bruno changes; existing tests/devserver/Bruno suite are unaffected.
## Out-of-scope note
Pre-existing flaky test `internal/crypto TestSealOpenRoundtrip`: for a 1-byte plaintext it asserts the byte is absent from the ~36-byte frame, which fails ~11% of the time by chance (unrelated to this PR; passes on rerun). Left as-is.
Closes #14
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this does
Adds
internal/publish: the realschedule.Publisher(#13's seam) that turns each pending, encrypted bug-report into a labeled GitHub issue on the target repo, and swaps it in for the no-opLogPublisherin the Worker.Publish flow (per pending id, oldest-first, capped at 50)
lifecycle.GetPending(id)->crypto.Open(decrypt with the Secrets-Store keyring) -> format into a well-formed Markdown issue body -> create the issue via the GitHub REST API with the three ADR #6 labels (bug-report,automated,needs-triage, created if missing) -> call theonPublished(ctx, id)hook. Per-report failures (fetch/decrypt/create/hook) are isolated and surfaced, never aborting the batch.GitHub client + ADR #6 limits (
internal/publish/github.go)Built on the standard
net/http(host-testable withhttptest; works under TinyGo js/wasm per #26 — no custom transport). All wall-clock behaviour is injected, so the retry policy is unit-tested with a virtual clock and zero real sleeps. Encodes ADR #6 §3.2 exactly:Retry-Afterexactly; wait untilx-ratelimit-resetwhenx-ratelimit-remaining: 0; >=60s floor for a secondary-rate-limit 403 withoutRetry-After; full-jitter exponential backoffmin(60s, 1s*2^attempt), <=5 attempts, on 5xx/network errors.Issue formatting wraps the (scrubbed but untrusted) report free-text in a length-adaptive code fence and metadata in inline code, neutralising Markdown/
@mentioninjection; oversize bodies are truncated with a marker.The
onPublishedseam for #15WithOnPublished(func(ctx, id) error)— default no-op — is invoked only after a confirmed 201 Created (ADR #6 "mark published only on confirmed success"). #15 wires it tolifecycle.MarkPublished(+ partial-failure orchestration) to complete cross-run de-dup: a marked report leaves the pending set so a later run never re-publishes it. This PR deliberately does not implement the mark-published transition — it leaves that seam clean and documented.Worker wiring
worker/scheduled_wasm.go: swapsschedule.LogPublisherfor the real publisher; readsGITHUB_TOKEN(Secrets Store, asyncget()like the keyring) andGITHUB_REPO(plain var, defaultJMR-dev/LibreMail); pre-checks the Friday-17:00-Central gate so the sibling cron fire does no secret I/O.schedule.Runre-applies the gate authoritatively.worker/main.gois untouched.storage.GetSecret(thin wrapper over the existing async secret read) so the token is read without duplicating plumbing.wrangler.jsonc: added theGITHUB_TOKENSecrets-Store secret and theGITHUB_REPOvar (only my keys; other sections untouched).Test evidence (host, no TinyGo)
go vet ./...clean,go test ./...green,GOOS=js GOARCH=wasm go build ./...compiles. Newinternal/publishtests (mock GitHub API viahttptest, virtual clock):onPublishedcalled once per success (also verified end-to-end through the reallifecycle.Manager).Retry-Afterretried per policy;x-ratelimit-resethonoured; secondary-limit floor; permission 403 not retried; attempt exhaustion.onPublished.No new HTTP server endpoint, so no Bruno changes; existing tests/devserver/Bruno suite are unaffected.
Out-of-scope note
Pre-existing flaky test
internal/crypto TestSealOpenRoundtrip: for a 1-byte plaintext it asserts the byte is absent from the ~36-byte frame, which fails ~11% of the time by chance (unrelated to this PR; passes on rerun). Left as-is.Closes #14