After a report is successfully published as a GitHub issue (#14), transition it to published via #10's lifecycle model, so it leaves the pending set and is not re-published on a later run.
The wiring (onPublished -> MarkPublished)
Building on #14's seam (the Publisher calls an injected onPublished(ctx, id) hook only after a confirmed 201, with per-report failures already isolated):
internal/publish gains a narrow Marker interface (MarkPublished(ctx, id) error, the write half of lifecycle.Manager, mirroring the existing PendingGetter read half) and a WithMarkPublished(m Marker) option. The option sets onPublished to call m.MarkPublished on each confirmed create. Keeping it behind a local interface means internal/publish stays host-testable and free of the Wasm-only storage backends.
worker/scheduled_wasm.go: buildPublish now constructs the publisher with publish.WithMarkPublished(manager). The single *lifecycle.Manager instance is both the pending getter and the marker. worker/main.go is untouched.
Partial-failure guarantee
Because the hook runs only on a confirmed 201 and per-report failures are isolated, the guarantee falls out naturally: in a run of N reports where report k fails to create, the reports that already succeeded were each marked published and drop out of the pending set, while k (and any later failure) was never marked and stays pending. The next run's ListPending returns only the still-pending reports and retries exactly those — never re-creating the already-published ones. After a full run, all successfully published reports show published; a report that failed to publish stays pending and is retried next run without duplication.
MarkPublished-failure edge case (honest note)
onPublished runs after the 201, so the issue already exists when MarkPublished runs. If the mark then fails, the report stays pending and will be re-published as a DUPLICATE next run — there is no GitHub-side idempotency key, and we cannot un-create the issue. Handled two ways: a loud log line naming the report and the duplicate risk, and the returned error (which fails the run so the maintainer is alerted). MarkPublished is itself idempotent, so a partially applied transition converges on any later mark; residual exposure is bounded to at most one duplicate, flagged loudly for manual reconciliation.
Test evidence
Host tests (go test ./..., no TinyGo) drive the real lifecycle.Manager over a MemoryStore + a mock issue creator across two runs (internal/publish/markpublished_test.go):
All succeed -> after the run all N are published (ListPending empty); a second run creates no new issues.
Partial failure -> report N's create fails; 1..N-1 are published, N stays pending; the next run retries only N (exactly one new issue) and does not duplicate 1..N-1.
Idempotency -> MarkPublished on an already-published report is a no-op success (one published object).
Mark-failure (bonus) -> surfaced, logged loudly, and honestly re-publishes once on recovery.
$ go vet ./...
(clean)
$ go test ./...
ok .../internal/crypto
ok .../internal/handler
ok .../internal/ingest
ok .../internal/lifecycle
ok .../internal/publish
ok .../internal/schedule
ok .../internal/scrub
ok .../internal/storage
$ GOOS=js GOARCH=wasm go build ./...
(compiles)
No new HTTP endpoint -> no Bruno. Existing tests/devserver unaffected.
## What
After a report is successfully published as a GitHub issue (#14), transition it to `published` via #10's lifecycle model, so it leaves the pending set and is not re-published on a later run.
## The wiring (onPublished -> MarkPublished)
Building on #14's seam (the Publisher calls an injected `onPublished(ctx, id)` hook **only** after a confirmed 201, with per-report failures already isolated):
- **`internal/publish`** gains a narrow `Marker` interface (`MarkPublished(ctx, id) error`, the write half of `lifecycle.Manager`, mirroring the existing `PendingGetter` read half) and a `WithMarkPublished(m Marker)` option. The option sets `onPublished` to call `m.MarkPublished` on each confirmed create. Keeping it behind a local interface means `internal/publish` stays host-testable and free of the Wasm-only storage backends.
- **`worker/scheduled_wasm.go`**: `buildPublish` now constructs the publisher with `publish.WithMarkPublished(manager)`. The single `*lifecycle.Manager` instance is both the pending getter and the marker. **`worker/main.go` is untouched.**
## Partial-failure guarantee
Because the hook runs **only on a confirmed 201** and per-report failures are isolated, the guarantee falls out naturally: in a run of N reports where report k fails to create, the reports that already succeeded were each marked `published` and drop out of the pending set, while k (and any later failure) was never marked and stays `pending`. The next run's `ListPending` returns only the still-pending reports and retries exactly those — never re-creating the already-published ones. After a full run, all successfully published reports show `published`; a report that failed to publish stays `pending` and is retried next run without duplication.
## MarkPublished-failure edge case (honest note)
`onPublished` runs *after* the 201, so the issue already exists when `MarkPublished` runs. If the mark then fails, the report stays `pending` and **will be re-published as a DUPLICATE next run** — there is no GitHub-side idempotency key, and we cannot un-create the issue. Handled two ways: a **loud log line** naming the report and the duplicate risk, and the **returned error** (which fails the run so the maintainer is alerted). `MarkPublished` is itself idempotent, so a partially applied transition converges on any later mark; residual exposure is bounded to at most one duplicate, flagged loudly for manual reconciliation.
## Test evidence
Host tests (`go test ./...`, no TinyGo) drive the real `lifecycle.Manager` over a `MemoryStore` + a mock issue creator across two runs (`internal/publish/markpublished_test.go`):
- **All succeed** -> after the run all N are `published` (`ListPending` empty); a second run creates **no** new issues.
- **Partial failure** -> report N's create fails; 1..N-1 are `published`, N stays `pending`; the next run retries **only** N (exactly one new issue) and does not duplicate 1..N-1.
- **Idempotency** -> `MarkPublished` on an already-published report is a no-op success (one published object).
- **Mark-failure** (bonus) -> surfaced, logged loudly, and honestly re-publishes once on recovery.
```
$ go vet ./...
(clean)
$ go test ./...
ok .../internal/crypto
ok .../internal/handler
ok .../internal/ingest
ok .../internal/lifecycle
ok .../internal/publish
ok .../internal/schedule
ok .../internal/scrub
ok .../internal/storage
$ GOOS=js GOARCH=wasm go build ./...
(compiles)
```
No new HTTP endpoint -> no Bruno. Existing tests/devserver unaffected.
Closes #15
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
After a report is successfully published as a GitHub issue (#14), transition it to
publishedvia #10's lifecycle model, so it leaves the pending set and is not re-published on a later run.The wiring (onPublished -> MarkPublished)
Building on #14's seam (the Publisher calls an injected
onPublished(ctx, id)hook only after a confirmed 201, with per-report failures already isolated):internal/publishgains a narrowMarkerinterface (MarkPublished(ctx, id) error, the write half oflifecycle.Manager, mirroring the existingPendingGetterread half) and aWithMarkPublished(m Marker)option. The option setsonPublishedto callm.MarkPublishedon each confirmed create. Keeping it behind a local interface meansinternal/publishstays host-testable and free of the Wasm-only storage backends.worker/scheduled_wasm.go:buildPublishnow constructs the publisher withpublish.WithMarkPublished(manager). The single*lifecycle.Managerinstance is both the pending getter and the marker.worker/main.gois untouched.Partial-failure guarantee
Because the hook runs only on a confirmed 201 and per-report failures are isolated, the guarantee falls out naturally: in a run of N reports where report k fails to create, the reports that already succeeded were each marked
publishedand drop out of the pending set, while k (and any later failure) was never marked and stayspending. The next run'sListPendingreturns only the still-pending reports and retries exactly those — never re-creating the already-published ones. After a full run, all successfully published reports showpublished; a report that failed to publish stayspendingand is retried next run without duplication.MarkPublished-failure edge case (honest note)
onPublishedruns after the 201, so the issue already exists whenMarkPublishedruns. If the mark then fails, the report stayspendingand will be re-published as a DUPLICATE next run — there is no GitHub-side idempotency key, and we cannot un-create the issue. Handled two ways: a loud log line naming the report and the duplicate risk, and the returned error (which fails the run so the maintainer is alerted).MarkPublishedis itself idempotent, so a partially applied transition converges on any later mark; residual exposure is bounded to at most one duplicate, flagged loudly for manual reconciliation.Test evidence
Host tests (
go test ./..., no TinyGo) drive the reallifecycle.Managerover aMemoryStore+ a mock issue creator across two runs (internal/publish/markpublished_test.go):published(ListPendingempty); a second run creates no new issues.published, N stayspending; the next run retries only N (exactly one new issue) and does not duplicate 1..N-1.MarkPublishedon an already-published report is a no-op success (one published object).No new HTTP endpoint -> no Bruno. Existing tests/devserver unaffected.
Closes #15