Publish approved WordPress comments to Bluesky as replies#32
Publish approved WordPress comments to Bluesky as replies#32
Conversation
Registered users commenting on posts that are already published to AT Protocol now publish outbound as app.bsky.feed.post reply records, with edit and unapprove/delete synced back to the record. Adds: - `Transformer\Comment` — reply ref resolution for local siblings, federated (Reaction_Sync) parents, or fallback to the post root. - `Publisher::publish_comment` / `update_comment` / `delete_comment` and TID-only `delete_comment_by_tid`. On success the reply's AT-URI is mirrored into `Reaction_Sync::META_SOURCE_ID` so the next inbound sync pass skips our own outbound comment via `find_comment_by_source_id`. - Comment lifecycle hooks on `Atmosphere` (`transition_comment_status`, `comment_post`, `edit_comment`, `delete_comment`) plus async cron handlers. Eligibility gate `Atmosphere::should_publish_comment` skips anon/trackback/unapproved/federated/unpublished-post inputs and honours `atmosphere_should_publish_comment`. Refactor: - Publisher renames to `publish_post` / `update_post` / `delete_post` / `delete_post_by_tids`; generic `publish` / `update` / `delete` dispatchers accept `WP_Post|WP_Comment` for polymorphic callers. - Test files consolidated so each test class maps to a real class: `Test_Publisher` absorbs comment tests, `Test_Reaction_Sync` absorbs the dedup regression, `Test_Atmosphere` replaces the old `Test_Status_Change` and absorbs the comment scheduler tests. Defence-in-depth: `is_comment_eligible` also skips comments stamped with the `ATmosphere/` agent so a hypothetical future path that fires `comment_post` from `wp_insert_comment` cannot produce a publish loop before Reaction_Sync writes META_PROTOCOL. Ignores docs/superpowers/ for local spec storage.
There was a problem hiding this comment.
Pull request overview
Adds first-class outbound publishing of eligible WordPress comments to Bluesky as threaded app.bsky.feed.post replies, including edit/delete sync and inbound deduping to avoid re-ingesting the plugin’s own outbound replies.
Changes:
- Introduces
Transformer\CommentandPublisher::{publish,update,delete}_commentto publish/update/delete Bluesky reply records for approved comments from registered users. - Wires comment lifecycle hooks in
Atmosphere(approve/insert/edit/delete) to schedule async publish/update/delete operations, with an eligibility gate and a filter. - Refactors
Publisherpost methods topublish_post/update_post/delete_post, adds generic polymorphic dispatch, and expands PHPUnit coverage for threading + dedup.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
includes/transformer/class-comment.php |
New transformer to map WP comments to Bluesky reply records with root/parent resolution. |
includes/class-publisher.php |
Adds comment publish/update/delete flows; refactors post methods and adds polymorphic dispatch. |
includes/class-atmosphere.php |
Registers comment hooks, implements comment eligibility gate, and schedules async comment jobs. |
includes/class-backfill.php |
Updates backfill publishing call site to renamed Publisher::publish_post. |
tests/phpunit/tests/transformer/class-test-comment.php |
New unit tests for comment transformer reply resolution, rkey persistence, and truncation/filtering. |
tests/phpunit/tests/class-test-publisher.php |
Adds comment publisher tests + dispatch tests; updates post publisher method names. |
tests/phpunit/tests/class-test-reaction-sync.php |
Adds regression test ensuring inbound sync skips locally-published outbound comments via source_id. |
tests/phpunit/tests/class-test-atmosphere.php |
Renames/expands tests to cover comment eligibility gate and existing scheduling behavior. |
.gitignore |
Ignores docs/superpowers/ local spec storage. |
.github/changelog/publish-comments |
Changelog entry for the new comment-to-Bluesky reply feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Four related fixes surfaced by the Copilot review: - Use META_URI (not META_TID) as the "already published" marker in Publisher::update_comment / delete_comment and in the comment schedulers. Comment::get_rkey() persists the TID locally before the API call, so a failed publish would otherwise leave the TID in place and send every subsequent retry down the #update path for a record that does not exist. Matches the pattern already used on the post flow. - Require both META_URI and META_CID on the parent post in is_comment_eligible. The transformer builds reply.root as a strongRef that needs both; missing CID produces an invalid record. - schedule_comment_delete now bails when the plugin is disconnected, matching the post-flow's connection guard. Prevents enqueueing cron events that have no credentials to execute and would only orphan the remote record. - on_comment_before_delete dedupes the TID-only cron event via wp_next_scheduled, matching the guard on the publish/update hooks. New tests cover each change, including a regression guard for the stale-TID retry loop.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- `Transformer\Comment::resolve_parent_ref` now requires both URI and CID for local and federated parents. A missing CID would produce an invalid strongRef; the resolver returns null so the caller falls back to the root ref instead, matching the post-root guard added in the previous round. - Drop the `static $post_id` cache in `make_eligible_comment`. `WP_UnitTestCase` rolls back DB state between tests, so a cached ID would leave later tests referencing a row that no longer exists. - Update `test_reply_to_federated_parent_without_cid_*` to assert root fallback rather than empty-CID pass-through, and add the matching local-parent-without-CID test.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
A comment's approval state can change between scheduling an async publish/update/delete and when WP-Cron actually fires the event. The transition hooks only schedule — they cannot cancel an already-queued event, and schedule_comment_delete bails when META_URI is absent (i.e. pre-publish), so a sequence like approve → unapprove would still publish the now-ineligible comment when cron ran. Each cron handler now re-runs the eligibility check: - atmosphere_publish_comment / atmosphere_update_comment — skip when should_publish_comment() is false at fire time. - atmosphere_delete_comment — skip when should_publish_comment() is true at fire time, so a re-approval between unapprove and cron keeps the remote record intact. Drive-by docstring fixes: - Publisher header now references site.standard.document (the actual lexicon collection) rather than "standard.site document". - Transformer\Comment header no longer references the removed Comment_Scheduler; Atmosphere::should_publish_comment is the gate.
kraftbj
left a comment
There was a problem hiding this comment.
Static code review only — no manual testing performed. Findings consolidated from the pr-review-toolkit agents and a Codex adversarial review pass.
Blocking
- Unpublishing a post orphans its published comment replies on the PDS.
delete_post()deletes only the root post + document records; reply records published from WordPress comments are not enumerated or deleted. AT Protocol deletes do not cascade, so those replies stay live after the root is gone. (Inline comment ondelete_post().) - Nested comments can publish before their parent and stay permanently mis-threaded. When a parent comment is eligible but has not yet published, the transformer silently falls back to making the root post the parent. Cron events have no dependency ordering, so the child can publish as a top-level reply and will not be re-parented once the actual parent publishes. (Inline comment on
build_reply_ref().) - Every new comment cron handler discards
WP_ErrorfromPublisher. Transient PDS 5xx, expired OAuth, DPoP nonce loops, and transport failures silently drop the event. WordPress state ends up inconsistent with Bluesky state with no log line, no retry, and no admin signal.atmosphere_delete_comment_recordis the worst case: the WP comment and its meta are already gone when the handler fires, so a failedapply_writesorphans the PDS record with nothing pointing back. (Inline comments on all four handlers.) store_comment_result()falls back to a locally-constructed URI when the PDS response lacksresults[0].uri. That URI is written to bothMETA_URIandMETA_SOURCE_IDas if publish succeeded — mis-routing future updates and deletes, and suppressing inbound-sync dedup. (Inline comment onstore_comment_result().)
Important
- Test-coverage gaps: schedule-layer hooks (
on_comment_status_change,on_comment_insert,on_comment_edit) and Publisher comment error-path side-effects. Five existing tests usemarkTestSkippedwhen the auth layer short-circuits, masking CI regressions. (Inline comments on the two test files.)
Suggestions
- Misleading docblock on
on_comment_before_delete: "a TID without URI is from a failed earlier publish" — actually that is the normal pre-publish state sinceComment::get_rkey()writes the TID eagerly. (Inline comment.)
Pre-existing, out-of-scope (worth a follow-up)
These came up in the review but target code not modified by this PR — flagging for a separate issue or PR:
- Reaction sync cron is scheduled on construct but only cleared on plugin deactivation. Revoking OAuth at runtime leaves an hourly orphan event.
Reaction_Sync::sync()short-circuits on! is_connected()so it is harmless, but the event never self-cleans. Fix in the disconnect path. Reaction_Sync::paginate()advances the watermark from the first item of the first page regardless of per-item success. A run of >10 item-level failures at the top of the stream permanently drops those items (WATERMARK_GRACE = 10). Track the highest successfully-processed URI separately, or explicitly document the 10-item grace as the only retry budget.Reaction_Sync::resolve_author()swallowsgetProfileerrors and caches no negative result. A rate-limited or flapping profile endpoint produces blank-authored comments and amplifies the rate-limit pressure. Log the error and cache a short negative result.Reaction_Sync::process_reply()/process_subject_reaction()cannot distinguish a dedup hit from a structurally malformed record. Both returnfalsewith no breadcrumb. Adderror_logon the non-dedup branches.
Strengths
- Defence-in-depth eligibility gate with full test coverage.
- Cron fire-time re-eligibility check correctly wired with explicit regression tests for approve→unapprove and unapprove→re-approve races.
update_comment()correctly keys offMETA_URIrather thanMETA_TID, with a named regression test.- Publish-loop prevention via
META_SOURCE_IDmirroring plus theATmosphere/agent sentinel, with explicit coverage. - Changelog entry is punctuated and user-facing.
Three classes of blocking issue surfaced by @kraftbj's Codex review: - Publisher::delete_post now cascades delete writes to every outbound comment reply on the post. AT Protocol has no cascade semantics; without this, unpublishing a post orphaned its comment reply records on the PDS with no pointer left in WordPress for cleanup. Stale TIDs from previously-failed publishes (META_TID present but META_URI absent) are excluded so the batch does not fail on non-existent records. - The atmosphere_publish_comment handler now defers when the comment's parent is eligible but has not yet published to the PDS. Without this, batch-approving a parent and a child together could fire the child's cron first and publish it flat as a top-level reply on the root post. A bounded counter (PARENT_DEFER_MAX_ATTEMPTS = 3, 30s between hops, stored as comment meta) prevents a stuck parent from blocking the child forever — after the cap the child publishes anyway using the root fallback. - All four comment cron handlers (publish, update, delete, and the TID-only delete_comment_record) now log WP_Error returns from Publisher::* instead of silently dropping them. A transient PDS outage, expired refresh token, or DPoP proof failure now leaves an error_log breadcrumb operators can act on. Tests cover the cascade-vs-stale-TID path, parent-pending deferral, and the proceed-after-cap behavior.
- `store_comment_result` no longer falls back to a locally-synthesized URI when applyWrites returns 2xx without `results[0].uri`. It now returns a WP_Error that publish_comment/update_comment propagate, so a malformed response is no longer indistinguishable from a clean publish (which would otherwise poison `META_URI` + the dedup key and steer later update/delete calls at a record that may not exist). Return type changed to `true|\WP_Error` per the WordPress success-or-error convention. - Rewrite the `on_comment_before_delete` docblock. The previous one claimed "TID without URI is from a failed earlier publish"; the TID is persisted eagerly by Comment::get_rkey before the API call, so TID-only is the normal pre-publish state too. The new docblock matches Publisher::update_comment's wording. - Add error-path tests for comment Publisher: publish writes no meta on API error, publish returns atmosphere_missing_uri when the 2xx response omits `results[0].uri`, update_comment + delete_comment preserve meta on API error. - Add schedule-layer tests for every comment lifecycle transition: approve/unapprove status changes, approved/unapproved/spam inserts, edit with/without META_URI, edit on an unapproved comment, and the TID-without-URI hard-delete guard.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
These were leftovers from an earlier revision that also called register_async_hooks in the tests. Removing a global action permanently unregisters the plugin's cron handler for the rest of the test run, making subsequent tests that rely on do_action( 'atmosphere_publish_comment', ... ) order-dependent and flaky. The plugin bootstrap registers these handlers once; the tests only need to clear the scheduled events they created, which they already do.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
kraftbj
left a comment
There was a problem hiding this comment.
Re-review after the trunk merges (PRs #33, #35, #37) and the two follow-up commits addressing my earlier review.
Static code review only — no manual testing. Findings consolidated from the pr-review-toolkit agents and a Codex adversarial review pass.
✅ Earlier-review items resolved
delete_post()now cascades comment replies viacollect_published_comment_tids().defer_when_parent_pending()defers child publishes when the parent is eligible-but-unpublished, with a 3-hop cap; covered by tests.log_cron_error()is wired for all four comment cron handlers.store_comment_result()returnsWP_Error( 'atmosphere_missing_uri' )instead of synthesizing a URI.- Misleading docblock on
on_comment_before_deleterewritten. - Schedule-layer + Publisher comment error-path test coverage added.
Nice work on the follow-up.
🔴 Must-fix (regression vs PR #35, which this branch just merged)
Neither file is in this PR's diff, so the inline anchor is on the hook-registration site. Both need updating in this branch:
atmosphere.phpdeactivate()(lines ~66–74 on this branch's tip): PR #35 addedwp_clear_scheduled_hook()for every plugin-owned cron and explicitly stated the goal in its changelog ("so leftover jobs don't linger after the plugin is removed"). The four new comment crons (atmosphere_publish_comment,atmosphere_update_comment,atmosphere_delete_comment,atmosphere_delete_comment_record) were added by this branch but not added to the cleanup list. Worst case after deactivate→uninstall→reinstall: a queuedatmosphere_delete_comment_recordfrom the previous install fires against the new install's connection, attempting a PDS delete on a record from a different repo (Publisher::delete_comment_by_tid()doesn't verify the TID belongs to the current connection).uninstall.phpneeds the same fourwp_clear_scheduled_hook()calls and acommentmetasweep for the new keys (_atmosphere_bsky_tid,_atmosphere_bsky_uri,_atmosphere_bsky_cid,_atmosphere_publish_attempts). The existing sweep operates on$wpdb->postmetaonly.
🟠 Should-fix
- Asymmetric cascade between the two delete paths — see inline comment on
Publisher::delete_post().
🟡 Follow-up tickets OK (Codex adversarial review)
- One-shot cron events permanently drop on transient API failures.
- Hard-delete loses durable state; only
error_logevidence remains. - Unbounded
applyWritesbatch on post-delete with many replies.
See inline comments on each.
Secondary observations (out of scope for this review pass)
Not raised inline since you only asked for items 1–3 of the recommended action order, but flagging here for your judgement:
defer_when_parent_pending()clears_atmosphere_publish_attemptsonly on the cap path. Non-defer early returns (parent gone,parent ineligible,parent already published) leave the counter stale, so a later edit-driven re-publish has a reduced defer budget. Low impact.on_comment_before_delete()silently returns whenMETA_URIis empty butMETA_TIDis present — this is correct for the normal pre-publish state, but masks the rare case whereMETA_URIwas cleared after a successful publish (filter, wp-cli, or external plugin). A breadcrumb on that branch would help operators surface orphans.store_comment_result()returningatmosphere_missing_urion a 2xx response that omitsresults[0].uriis more correct than the prior synthesized-URI fallback, but the PDS write actually succeeded — that becomes an orphaned record on Bluesky with no WP-side pointer and no automatic recovery. Consider including the locally-generated TID in the WP_Error so logs can map back to the orphan, paving the way for a reconciliation job.
PR #35 established the pattern of clearing every plugin-owned cron in deactivate() and uninstall.php so leftover jobs don't linger after the plugin is removed. The four new comment crons added in this PR (atmosphere_publish_comment, atmosphere_update_comment, atmosphere_delete_comment, atmosphere_delete_comment_record) were missing from both lists. uninstall.php's commentmeta sweep was also missing — it cleaned postmeta only. Without this, a queued atmosphere_delete_comment_record from a previous install can fire against a new install's connection after uninstall + reinstall, since delete_comment_by_tid() does not verify the TID belongs to the current connection.
The trash path (Publisher::delete_post) already enumerated published comment replies via collect_published_comment_tids() and bundled them into the apply_writes batch. The permanent-delete path (on_before_delete → atmosphere_delete_records → delete_post_by_tids) only carried the post and document TIDs, leaving comment reply records to be cleaned up implicitly by WP's natural delete_comment cascade. That is fragile against future core changes and against sites that narrow the syncable post-type filter after publishing. on_before_delete now enumerates published comment TIDs at the only point where they are still readable (before_delete_post fires before WP iterates child comments) and threads them through the cron event. delete_post_by_tids accepts the new $comment_tids argument and appends one applyWrites#delete per reply to the same batch. The per-comment atmosphere_delete_comment_record cron still fires via WP's cascade and provides a resilient fallback if the consolidated batch errors — at the cost of noisy "record not found" log lines on the happy path, which is the right trade for not losing comment TIDs when apply_writes fails atomically.
|
works quite well: https://bsky.app/profile/pfefferle.org/post/3mjz3o3gtjk2o |
kraftbj
left a comment
There was a problem hiding this comment.
Third-pass review after 0974054 (deactivate/uninstall cron clearing + commentmeta sweep) and 612fa037 (cascade through permanent-delete path), plus the trunk merges of PR #34 (long-form teaser-thread) and PR #38 (split publish/cleanup gates).
Static code review only — no manual testing. Findings consolidated from the pr-review-toolkit agents and a Codex adversarial review.
✅ Prior must-fix / should-fix items: all resolved
deactivate()clears the four new comment crons;uninstall.phpclears them and sweeps the new commentmeta keys.- Permanent-delete cascade now enumerates published comment TIDs in
on_before_delete()and threads them throughdelete_post_by_tids(). - Trunk merges (PR #34, PR #38) integrate cleanly with comment publishing (comments correctly stay outside long-form composition, and the publish/cleanup gate split is consistent with the META_URI-based comment gate).
🔴 New blocking findings (Codex + silent-failure-hunter)
See inline comments. Three of these are post-merge regressions; one is a Codex adversarial finding; two are teardown-completeness gaps that mirror the cleanup work in 0974054.
🟡 Note (not blocking)
Codex flagged that is_comment_eligible() doesn't gate on the parent post's post_status or is_supported_post_type() — it only checks for stale META_URI/META_CID. See inline comment for context. Likely intentional given the publish/cleanup gate philosophy from PR #38, but worth a deliberate decision.
Still open from prior reviews (out of scope for this pass)
Not raised inline this round — already documented in earlier reviews:
- One-shot cron events permanently dropped on transient API failures.
- Hard-delete loses durable state on transient API failure.
defer_when_parent_pendingcounter leak on non-defer return paths.on_comment_before_deletesilent drop conflates safe and orphan states.store_comment_resultproduces a wedged comment state on 2xx-without-uri.
- Extract Atmosphere\get_cron_hooks / clear_scheduled_hooks so deactivate, Client::disconnect, and uninstall.php share one canonical hook list; prevents queued events from a previous connection firing against the current repo on reconnect or reactivate. - Log WP_Error from atmosphere_delete_records cron so a failed cascade leaves an operator-visible breadcrumb instead of silently leaking every reply record on the post. - Chunk applyWrites batches into 100-write groups in delete_post and delete_post_by_tids; the lexicon caps a single batch at 200, and cascading 200+ replies through one atomic call would fail with InvalidRequest. WP_Error now carries chunk_index/total/succeeded. - Reconcile after publish_comment: re-check eligibility once applyWrites returns success; if the comment was unapproved or deleted during the in-flight call, clear the just-written meta and schedule the TID-only delete cron so transient PDS errors retry through the standard channel. - Capture findings in code-style and test skills (cron-lifecycle three-way symmetry, never-swallow-WP_Error in cron handlers, in-flight race pattern, applyWrites stubbing for tests).
kraftbj
left a comment
There was a problem hiding this comment.
Reviewed — no remaining concerns. LGTM.
Proposed changes:
Scope
app.bsky.feed.postreply records. Nosite.standard.documentorsite.standard.commentis written — standard.site has no comment NSID, so there is no lexicon target. (Posts remain dual-written as bsky + document.)$comment->user_id > 0.Post::META_URIandPost::META_CID(needed to build a valid reply.root strongRef); comments on not-yet-synced posts are skipped.Feature
Transformer\Commentturns an approved\WP_Commentinto anapp.bsky.feed.postreply record. Root is always the parent post's bsky record; parent resolves to a locally-published sibling comment, a federated (Reaction_Sync) parent, or falls back to the root when either URI or CID is missing (strongRef requires both).Publisher::publish_comment/update_comment/delete_commentmethods plus a TID-onlydelete_comment_by_tidfor hard-delete after the row is gone.update_commentanddelete_commentkey offMETA_URI(set only after a successful API call), notMETA_TID(persisted locally byget_rkey()), so a previously-failed publish correctly retries as a create.Atmosphere:transition_comment_status,comment_post,edit_comment, anddelete_commentschedule async publish / update / delete jobs via WP-Cron.Atmosphere::should_publish_comment()exposes the gate and honorsatmosphere_should_publish_comment. Filter-forward:atmosphere_transform_commentmutates the record before publish.Dedup contract (loop prevention)
Reaction_Sync::META_SOURCE_ID. WhenReaction_Sync::process_replynext sees the same URI come back vialistRecords,find_comment_by_source_idmatches the local row and skips it — no duplicate row, no publish loop.is_comment_eligible: comments stamped with the plugin's ownATmosphere/agent string (asReaction_Sync::insert_reactiondoes) are rejected even ifMETA_PROTOCOLhas not yet been written.Refactor (bundled)
Publisher::publish/update/deleterenamed topublish_post/update_post/delete_post(anddelete_by_tids→delete_post_by_tids). The generic entry pointspublish/update/deletenow accept\WP_Post|\WP_Commentand dispatch by type, so polymorphic callers stay simple while async hooks that know their type call the explicit variant.Test_Publisherabsorbs the comment tests,Test_Reaction_Syncabsorbs the dedup regression, andTest_Atmospherereplaces the oldTest_Status_Changeand absorbs the comment eligibility tests.Other information:
Testing instructions:
wp cron event run atmosphere_publish_commentfires: nothing publishes (eligibility is re-checked at fire time).wp cron event run atmosphere_sync_reactions) and confirm your outbound comment is not re-ingested as a new WP comment.reply.parent.Changelog entry
Changelog Entry Details
Significance
Type
Message
Publish replies from registered WordPress users to Bluesky as native replies, with edit and unapprove/delete synced back to the AT Protocol record.