Skip to content

Added X integration for automatic post sharing#27366

Open
doronkatz wants to merge 6 commits intoTryGhost:mainfrom
doronkatz:dor-599-enable-auto-post-to-xtwitter
Open

Added X integration for automatic post sharing#27366
doronkatz wants to merge 6 commits intoTryGhost:mainfrom
doronkatz:dor-599-enable-auto-post-to-xtwitter

Conversation

@doronkatz
Copy link
Copy Markdown

@doronkatz doronkatz commented Apr 13, 2026

What

  • add a built-in X integration with persisted settings, OAuth 1.0a request-token handling, and automatic posting on publish
  • add per-post opt-out storage and editor controls so articles can disable X auto-posting individually
  • switch the X connection flow to an out-of-band verifier flow that works with the provided desktop-app credentials
  • remove tracked X consumer credentials from committed defaults and rely on runtime config/env overrides

Testing

  • cd ghost/core && mise exec node@22.13.1 -- pnpm test:single test/unit/server/services/settings/settings-utils.test.js
  • cd ghost/core && mise exec node@22.13.1 -- pnpm test:single test/unit/server/api/endpoints/utils/serializers/output/posts.test.js
  • cd ghost/core && mise exec node@22.13.1 -- pnpm test:single test/unit/server/services/posts/posts-service.test.js
  • cd ghost/core && mise exec node@22.13.1 -- pnpm test:single test/unit/server/services/x.test.js
  • mise exec node@22.13.1 -- pnpm nx run @tryghost/admin-x-framework:build
  • cd apps/admin-x-settings && mise exec node@22.13.1 -- pnpm test:acceptance test/acceptance/advanced/integrations/x.test.ts
  • cd ghost/admin && mise exec node@22.18.0 -- pnpm exec ember test --filter="saves post settings fields" tests/acceptance/editor-test.js
  • runtime auth-start verification against the supplied X app credentials via xService.getAuthorizationUrl(...)

Refs DOR-599


Note

High Risk
High risk because this introduces new admin API endpoints, stores/obfuscates OAuth credentials, adds a DB migration, and hooks into the publish event to call an external service—any issues could affect publishing or leak/mishandle tokens.

Overview
Adds a built-in X integration end-to-end: new admin UI entry + modal for connecting via out-of-band OAuth verifier, verifying/disconnecting, and showing connected state.

Implements new admin API endpoints (/x/, /x/verify/, DELETE /x/) plus a backend x service that performs OAuth 1.0a request-token/access-token flows, stores X credentials in settings (now grouped as x), and publishes a post link to X on post.published when configured.

Introduces a posts_meta.x_post_enabled flag (DB migration + editor toggle) to opt out per post, ensures the flag is preserved through serialization/copy flows, and expands secret-setting detection to obfuscate *_access_token* values in settings responses.

Reviewed by Cursor Bugbot for commit 4b1497b. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions bot added the migration [pull request] Includes migration for review label Apr 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

It looks like this PR contains a migration 👀
Here's the checklist for reviewing migrations:

General requirements

  • ⚠️ Tested performance on staging database servers, as performance on local machines is not comparable to a production environment
  • Satisfies idempotency requirement (both up() and down())
  • Does not reference models
  • Filename is in the correct format (and correctly ordered)
  • Targets the next minor version
  • All code paths have appropriate log messages
  • Uses the correct utils
  • Contains a minimal changeset
  • Does not mix DDL/DML operations
  • Tested in MySQL and SQLite

Schema changes

  • Both schema change and related migration have been implemented
  • For index changes: has been performance tested for large tables
  • For new tables/columns: fields use the appropriate predefined field lengths
  • For new tables/columns: field names follow the appropriate conventions
  • Does not drop a non-alpha table outside of a major version

Data changes

  • Mass updates/inserts are batched appropriately
  • Does not loop over large tables/datasets
  • Defends against missing or invalid data
  • For settings updates: follows the appropriate guidelines

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Walkthrough

The pull request adds comprehensive agent skill definitions and an end-to-end automated workflow orchestration framework, alongside a complete X (Twitter) integration for Ghost. New agent skills document structured Git workflows (commit, pull, push), PR merging (land), debugging, Linear issue tracking, and app launching. A WORKFLOW.md file defines state-based automation for Linear tickets. The X integration includes backend OAuth 1.0a authentication, auto-post-to-X functionality on post publication, settings management, API endpoints, database schema extensions, and frontend UI components. Supporting changes include test coverage, migrations, serializer updates, configuration defaults, and a Python-based PR watcher utility for concurrent CI and review monitoring.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Added X integration for automatic post sharing' accurately and concisely summarizes the main feature addition in the changeset.
Description check ✅ Passed The PR description comprehensively covers the changeset's key aspects: OAuth integration, per-post opt-out controls, connection flow changes, credential handling, and testing instructions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@doronkatz
Copy link
Copy Markdown
Author

Addressing the migration checklist:

  • the migration targets 6.29, updates schema.js/default settings in the same changeset, and uses the standard migration utils
  • it does not reference models, keeps DDL and DML separated via combineNonTransactionalMigrations(...), and includes existence checks plus log messages in both directions
  • the data change is limited to inserting four settings rows and the schema change is a single boolean column on posts_meta; there is no large-table backfill/update path
  • targeted validation for the feature is green (x service tests, serializer/settings/posts-service coverage, Admin-X acceptance, Ember post-settings acceptance, and runtime auth-start proof with the supplied X app credentials)
  • I did not run staging-scale performance verification in-session because this migration does not perform a mass update or add an index, so there is no production-scale scan/rewrite path to benchmark beyond the normal DDL operation itself

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4b1497b. Configure here.

authors: model.related('authors').toJSON()
}).catch((error) => {
logging.error(error);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-post X opt-out never works at runtime

High Severity

The xListener calls model.toJSON() on the Bookshelf post model, which serializes posts_meta as a nested object (e.g., {posts_meta: {x_post_enabled: false}}). But publishPost checks post.x_post_enabled === false at the top level, which will always be undefined. Since undefined === false is false, the per-post opt-out check never triggers, and posts are always auto-published to X even when the user explicitly disabled it.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4b1497b. Configure here.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b1497b668

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return;
}

if (post.x_post_enabled === false) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read per-post X opt-out flag before publishing

The per-post toggle is persisted in posts_meta.x_post_enabled, but this guard checks post.x_post_enabled at the top level. In the same file, xListener forwards model.toJSON() to publishPost, which keeps metadata nested under posts_meta, so this condition is typically undefined !== false and the skip never triggers. In practice, posts that authors explicitly disabled for X auto-posting can still be published to X when the integration is connected.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (8)
ghost/admin/tests/acceptance/editor-test.js-388-391 (1)

388-391: ⚠️ Potential issue | 🟡 Minor

Tighten the initial xPostEnabled assertion.

to.not.equal(false) also passes when the field is undefined or null, so this test would miss a broken default or missing serialization. Assert true explicitly, or check the checkbox state instead.

Suggested fix
             expect(
                 this.server.db.posts.find(post.id).xPostEnabled,
                 'x posting starts enabled'
-            ).to.not.equal(false);
+            ).to.equal(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/admin/tests/acceptance/editor-test.js` around lines 388 - 391, The
assertion for xPostEnabled is too loose (it allows undefined/null); update the
expectation on this.server.db.posts.find(post.id).xPostEnabled to assert true
explicitly (use expect(...).to.equal(true)) or alternatively assert the UI
checkbox state tied to xPostEnabled to ensure the default is serialized
correctly; adjust the test in editor-test.js where the current
expect(...).to.not.equal(false) is used so it verifies a concrete true value.
ghost/core/core/server/data/migrations/versions/6.29/2026-04-13-00-12-50-add-x-auto-posting.js-56-70 (1)

56-70: ⚠️ Potential issue | 🟡 Minor

Don't delete settings that this migration may not have created.

up() explicitly skips any pre-existing x_* row, but down() deletes every matching key unconditionally. That makes rollback non-symmetric and can remove manually provisioned data that predates this migration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/data/migrations/versions/6.29/2026-04-13-00-12-50-add-x-auto-posting.js`
around lines 56 - 70, The down() migration currently deletes any matching x_*
key unconditionally; change it to only remove settings that this migration
actually created by comparing the existing row's value to the expected value
from X_SETTINGS before deleting. In the down function, for each setting in
X_SETTINGS check existingSetting.value === setting.value (or another unique
marker set by up) and only call .del() when it matches; otherwise log and skip
to avoid removing pre-existing/manual settings.
apps/admin-x-settings/test/acceptance/advanced/integrations/x.test.ts-101-104 (1)

101-104: ⚠️ Potential issue | 🟡 Minor

Wait for the disconnect request before asserting it fired.

lastApiRequests.disconnectX is populated asynchronously after the confirmation click, so this direct assertion can race and intermittently fail.

💡 Suggested fix
-        expect(lastApiRequests.disconnectX).toBeTruthy();
+        await expect.poll(() => lastApiRequests.disconnectX).toBeTruthy();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-x-settings/test/acceptance/advanced/integrations/x.test.ts` around
lines 101 - 104, The assertion on lastApiRequests.disconnectX races because it's
set asynchronously after the confirmation click; after calling
xModal.getByRole(...).click() and
page.getByTestId('confirmation-modal').getByRole(...).click(), wait for
lastApiRequests.disconnectX to be populated before asserting (e.g., use a short
polling loop or test helper like waitFor that repeatedly checks
lastApiRequests.disconnectX until truthy, or await a page.waitForResponse
matching the X disconnect endpoint), then assert
expect(lastApiRequests.disconnectX).toBeTruthy(); reference the existing symbols
lastApiRequests.disconnectX, xModal.getByRole, and
page.getByTestId('confirmation-modal').getByRole when implementing the wait.
.agents/skills/land/land_watch.py-577-582 (1)

577-582: ⚠️ Potential issue | 🟡 Minor

Document exit code 5 before callers depend on this watcher contract.

This script now exits with SystemExit(5) for merge conflicts, but .agents/skills/land/SKILL.md:110-135 only documents exit codes 2, 3, and 4. Anything branching on the documented outcomes will treat merge conflicts as an unknown failure.

Also applies to: 591-596

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/land/land_watch.py around lines 577 - 582, The watcher now
raises SystemExit(5) when is_merge_conflicting(pr) in land_watch.py (also the
same pattern at the second occurrence around the other raise), so update
.agents/skills/land/SKILL.md to document exit code 5 with a brief description
(e.g., "exit code 5 — PR has merge conflicts; resolve/rebase against main and
push") and ensure callers that rely on documented outcomes know to treat 5 as a
handled/expected state; verify both occurrences of SystemExit(5) are covered in
the documentation.
.codex/skills/commit/SKILL.md-60-75 (1)

60-75: ⚠️ Potential issue | 🟡 Minor

Add a language to the fenced template block.

This new file currently fails markdownlint (MD040) because the commit-message example fence is untyped.

🧹 Proposed fix
-```
+```text
 <type>(<scope>): <short summary>
 
 Summary:
 - <what changed>
@@
 
 Co-authored-by: Codex <codex@openai.com>
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @.codex/skills/commit/SKILL.md around lines 60 - 75, The fenced code block
for the commit-message template is missing a language identifier causing
markdownlint MD040; update the opening triple-backtick of the example block (the
block that starts with and contains the commit template with "Summary:", "Rationale:", "Tests:", and "Co-authored-by:") to include a language such as "text" (i.e., change the opening to ```text) so the fence is typed and MD040
is resolved.


</details>

</blockquote></details>
<details>
<summary>.agents/skills/pull/SKILL.md-24-24 (1)</summary><blockquote>

`24-24`: _⚠️ Potential issue_ | _🟡 Minor_

**Document minimum Git version requirement (2.35) for `merge.conflictstyle=zdiff3`.**

The `zdiff3` conflict style (line 28) requires Git 2.35+, which is not documented in this skill. While the repository supports this version, the skill should explicitly state the minimum Git version required for reliable execution, particularly for the more recent `zdiff3` feature that was introduced in January 2022.

Consider adding a prerequisites section noting Git 2.35+ is required, or providing a fallback merge strategy for older Git versions.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/pull/SKILL.md at line 24, Update the SKILL.md to document the
minimum Git version required for the zdiff3 conflict style: state that Git 2.35+
is required (zdiff3 introduced Jan 2022) and add this to a "Prerequisites"
section; reference the existing mention of merge.conflictstyle=zdiff3 so readers
see the requirement, and optionally add guidance for a fallback merge strategy
or configuration for older Git versions (e.g., alternate conflict style) so the
skill can run reliably on older environments.
```

</details>

</blockquote></details>
<details>
<summary>.codex/skills/land/SKILL.md-110-115 (1)</summary><blockquote>

`110-115`: _⚠️ Potential issue_ | _🟡 Minor_

**Document exit code 5 too.**

The watcher also exits with code 5 when the PR is merge-conflicting, so this table is currently incomplete. Any caller that only maps 2/3/4 will mis-handle the conflict path.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In @.codex/skills/land/SKILL.md around lines 110 - 115, The Exit codes table is
missing an entry for the watcher exit code 5; update the "Exit codes:" list in
SKILL.md to include "5: PR merge-conflicting" (or similar wording like "5: PR
merge-conflicting (merge conflict detected)") so callers mapping exit codes
handle the conflict path; ensure the new entry follows the existing format
alongside 2/3/4 in the same section.
```

</details>

</blockquote></details>
<details>
<summary>.agents/skills/land/SKILL.md-110-115 (1)</summary><blockquote>

`110-115`: _⚠️ Potential issue_ | _🟡 Minor_

**Document exit code 5 too.**

The watcher also exits with code 5 when the PR is merge-conflicting, so this table is incomplete for anyone automating against it.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/land/SKILL.md around lines 110 - 115, Update the Exit codes
list in SKILL.md to include the missing code 5: add an entry such as "- 5: PR
merge-conflicting (watcher detected merge conflict)" so the table documents the
watcher exit when a PR is merge-conflicting; modify the existing Exit codes
section (the list containing codes 2, 3, 4) to insert this new line and keep
formatting consistent with the other entries.
```

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>apps/admin-x-settings/src/components/settings/advanced/integrations/x-modal.tsx (1)</summary><blockquote>

`6-21`: **Avoid forcing these settings to `string` during destructuring.**

`getSettingValues<string>()` hides the fact that both keys can be `null` at runtime, so malformed or missing settings still type-check as strings here. Read them with runtime `typeof === 'string'` guards before deriving `isConnected`.

<details>
<summary>💡 Suggested refactor</summary>

```diff
-import {getSettingValues, useDeleteXSettings, useVerifyXSettings} from '@tryghost/admin-x-framework/api/settings';
+import {getSettingValue, useDeleteXSettings, useVerifyXSettings} from '@tryghost/admin-x-framework/api/settings';
@@
-    const [xAccessToken, xUsername] = getSettingValues<string>(settings, ['x_access_token', 'x_username']);
+    const rawAccessToken = getSettingValue(settings, 'x_access_token');
+    const rawUsername = getSettingValue(settings, 'x_username');
+    const xAccessToken = typeof rawAccessToken === 'string' ? rawAccessToken : null;
+    const xUsername = typeof rawUsername === 'string' ? rawUsername : null;
```
</details>
Based on learnings, "avoid unsafe type assertions for destructuring settings values... perform runtime type checks, and apply sensible defaults."

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/advanced/integrations/x-modal.tsx`
around lines 6 - 21, The code currently forces settings to string via
getSettingValues<string>(...) and then derives isConnected from
xAccessToken/xUsername, which can mask null values; change the call to
getSettingValues without the <string> generic and treat the returned
xAccessToken and xUsername as possibly null/unknown, perform runtime typeof
checks (e.g. typeof xAccessToken === 'string' && typeof xUsername === 'string')
before computing isConnected, and apply sensible defaults or null-handling where
xAccessToken, xUsername, or isConnected are used (refer to getSettingValues,
xAccessToken, xUsername, and isConnected to locate and update the logic).
```

</details>

</blockquote></details>
<details>
<summary>.agents/skills/pull/SKILL.md (2)</summary><blockquote>

`23-26`: **Consider adding error handling guidance for fast-forward pull failures.**

Step 5 uses `git pull --ff-only`, which will fail if the remote branch has diverged from the local branch (e.g., force-pushed or has conflicting commits). Consider adding guidance on what to do when this command fails.



<details>
<summary>📝 Suggested addition for error handling</summary>

Add after line 26:

```diff
     before merging `origin/main`.
+   - If `--ff-only` fails (diverged history), verify the remote changes are
+     safe, then either:
+     - Use `git pull --rebase origin $(git branch --show-current)`, or
+     - Reset to the remote: `git reset --hard origin/$(git branch --show-current)`
+       (only if local commits should be discarded).
 6. Merge in order:
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/pull/SKILL.md around lines 23 - 26, Update the SKILL.md step
that uses "git pull --ff-only" to include error-handling guidance: detect a
non-zero exit from git pull --ff-only, run git fetch and inspect divergence with
git status or git log --left-right --graph origin/$(git branch
--show-current)...HEAD, then describe safe recovery options (rebase onto origin,
merge, or reset --hard if force-pushed) and remind to back up local changes
(stash or create a temp branch) before destructive commands; reference the
existing step text ("git pull --ff-only origin $(git branch --show-current)") so
reviewers can insert a short paragraph explaining these diagnostics and the
recommended actions.
```

</details>

---

`28-29`: **Document Git version requirement or provide fallback for `zdiff3` conflict style.**

The `zdiff3` conflict style was introduced in Git 2.35 (January 2022). Since SKILL.md is used across various deployment environments, consider either:
- Adding a note that `zdiff3` requires Git 2.35+ (verified to work with Git 2.39+), or
- Providing a fallback command: `git -c merge.conflictstyle=diff3 merge origin/main` for older versions.

This ensures agents using this skill can adapt if their environment uses an older Git version.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/pull/SKILL.md around lines 28 - 29, Update SKILL.md to
document that the git merge.conflictstyle value "zdiff3" requires Git 2.35+
(note that it was verified with 2.39+) and add a fallback suggestion using
"diff3" for older Git versions; specifically mention the example command shown
(`git -c merge.conflictstyle=zdiff3 merge origin/main`) and provide the
alternative (`git -c merge.conflictstyle=diff3 merge origin/main`) so users
without zdiff3 know which command to run or to upgrade Git.
```

</details>

</blockquote></details>
<details>
<summary>ghost/core/test/unit/server/api/endpoints/utils/serializers/output/posts.test.js (1)</summary><blockquote>

`13-16`: **Refactor rewire cleanup to prevent test pollution.**

`sinon.restore()` only resets Sinon fakes; it does not restore the module-level bindings changed by `postsMapper.__set__`. While this file currently has only one test, the pattern should be fixed to prevent future tests from inheriting mocked `url`, `extraAttrs`, `clean`, and `utils`.

Use `__set__` with an object to capture the revert function, and call it in a `finally` block:

<details>
<summary>Suggested refactor</summary>

```diff
     it('preserves false values for x_post_enabled', async function () {
-        postsMapper.__set__('url', {forPost: sinon.stub()});
-        postsMapper.__set__('extraAttrs', {forPost: sinon.stub()});
-        postsMapper.__set__('clean', {post: sinon.stub()});
-        postsMapper.__set__('utils', {isContentAPI: () => false});
-
-        const result = await postsMapper({
-            id: 'post_1',
-            email_recipient_filter: null,
-            posts_meta: {
-                x_post_enabled: false
-            }
-        }, {
-            options: {}
-        }, {
-            tiers: []
-        });
-
-        assert.equal(result.x_post_enabled, false);
+        const restoreRewire = postsMapper.__set__({
+            url: {forPost: sinon.stub()},
+            extraAttrs: {forPost: sinon.stub()},
+            clean: {post: sinon.stub()},
+            utils: {isContentAPI: () => false}
+        });
+
+        try {
+            const result = await postsMapper({
+                id: 'post_1',
+                email_recipient_filter: null,
+                posts_meta: {
+                    x_post_enabled: false
+                }
+            }, {
+                options: {}
+            }, {
+                tiers: []
+            });
+
+            assert.equal(result.x_post_enabled, false);
+        } finally {
+            restoreRewire();
+        }
     });
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/test/unit/server/api/endpoints/utils/serializers/output/posts.test.js`
around lines 13 - 16, Replace the current multiple postsMapper.__set__ calls
with a single call that captures the revert function (e.g., const revert =
postsMapper.__set__({url: ..., extraAttrs: ..., clean: ..., utils: ...})), then
ensure you call revert() in a finally block (alongside sinon.restore()) so the
module-level bindings are restored after the test; target the setup where
postsMapper.__set__ is currently used and wrap the test execution in try/finally
to call revert() to avoid polluting other tests.
```

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.agents/skills/commit/SKILL.md:

  • Around line 29-34: Replace the risky instruction that tells users to "Stage
    intended changes, including new files (git add -A)" with guidance to stage only
    intended changes—e.g., recommend interactive or path-specific staging (replace
    the phrase "git add -A" in that line) and update surrounding steps so step 4
    instructs checking staged changes (git status/git diff --staged) before
    committing and step 5 advises amending the index or asking for confirmation if
    unrelated files are present; ensure the SKILL.md text explicitly suggests safer
    alternatives to git add -A and clarifies the workflow for selective commits.

In @.agents/skills/debug/SKILL.md:

  • Around line 53-55: The current rg command (rg -o "session_id=[^ ;]+"
    log/symphony.log*) scans all logs and can return unrelated sessions; change it
    to pipe from the ticket-specific filtered output instead of scanning
    log/symphony.log* directly — i.e., reuse the prior ticket-filtered match stream
    (the earlier rg/filter that narrows to the ticket) as the input and then extract
    session IDs by applying the session_id regex (the "session_id=[^ ;]+" pattern)
    and sort -u; update the step that references rg -o "session_id=[^ ;]+" to
    operate on the ticket-filtered lines rather than all log files.

In @.agents/skills/land/land_watch.py:

  • Around line 306-327: The code computes a latest review-request cutoff
    (latest_review_request_at) but never applies it when evaluating human feedback,
    so older unresolved feedback can still trigger raise_on_human_feedback; update
    filter_human_issue_comments and also apply the same cutoff in
    filter_codex_review_issue_comments, filter_human_review_comments, and
    is_blocking_review so they ignore comments/reviews with comment_time() (or
    created_time) <= latest_review_request_at() before raise_on_human_feedback is
    called; ensure you reference and use latest_review_request_at() (or pass
    latest_ack) as the gating value and skip items earlier than that in those
    functions (e.g., inside filter_human_issue_comments,
    filter_codex_review_issue_comments, filter_human_review_comments, and
    is_blocking_review) so only activity after the most recent "@codex review" is
    considered.

In @.agents/skills/push/SKILL.md:

  • Around line 47-55: The documented PR-body flow is a no-op because the template
    path and GH CLI invocation are wrong: update references from
    .github/pull_request_template.md to the actual file
    .github/PULL_REQUEST_TEMPLATE.md (or rename the template to the referenced
    lowercase name) and modify the gh command used in the workflow/script so it
    passes the template as the body file (use gh pr create/edit --body-file
    or the equivalent flag your CLI version supports) instead of only
    validating the existing body; also ensure mix pr_body.check is pointed at the
    correct template path or updated to validate the file that will actually be
    supplied to gh.
  • Around line 29-30: The SKILL.md includes hardcoded Elixir validation commands
    ("make -C elixir all" and "(cd elixir && mix pr_body.check ...)") that will fail
    because there is no elixir/ workspace; remove these commands or wrap them with a
    presence check: either replace them with the repository's actual validation
    steps or add a conditional gate that detects the elixir/ directory and the mix
    task before invoking "make -C elixir all" or "mix pr_body.check" so the push
    skill skips those steps gracefully when the workspace or task is absent.
  • In @.codex/skills/debug/SKILL.md:

    • Around line 53-55: The current extractor rg -o "session_id=[^ ;]+" against
      log/symphony.log* can return session_ids from unrelated tickets; restrict the
      search to lines for the ticket being debugged by filtering the logs for that
      ticket first (e.g., pipe or chain a search for the ticket identifier) and then
      run the rg -o "session_id=[^ ;]+" extractor and sort -u on the ticket-scoped
      output so only session_id values seen for that ticket are returned.

    In @.codex/skills/land/land_watch.py:

    • Around line 575-600: The current mergeability check in watch_pr uses
      is_merge_conflicting(pr) which only treats CONFLICTING/DIRTY as blocking, so if
      GitHub returns mergeable = UNKNOWN/null we can proceed prematurely; modify
      watch_pr (and the inner head_monitor) to poll get_pr_info until mergeable is
      resolved (i.e., not UNKNOWN/null) before proceeding: loop with
      asyncio.sleep(POLL_SECONDS) and a reasonable timeout/retry count, re-evaluating
      is_merge_conflicting(current) once mergeable is known, and only continue or exit
      after mergeability has settled (use existing POLL_SECONDS and raise
      SystemExit(5) on confirmed conflicts as currently done). Ensure the initial
      pre-check for pr and the head_monitor both implement this retry-until-resolved
      behavior.
    • Around line 523-543: The watcher currently allows exit as soon as CI finishes
      even when the latest review_request_at has not yet produced any Codex comment;
      update the logic that collects bot_comments (bot_issue_comments +
      bot_review_comments) to compute bot_comments_after_request = [c for c in
      bot_comments if parse_time(c["created_at"]) >= review_request_at] and use that
      list for the “latest”/message/exit decision so that if no bot comment exists
      after review_request_at the function does not proceed (i.e., do not treat
      checks_done.is_set() as success when bot_comments_after_request is empty); keep
      raise_on_human_feedback as-is and only inspect/latest/sanitize/print/raise
      SystemExit(2) when bot_comments_after_request contains entries.

    In @.codex/skills/push/SKILL.md:

    • Line 29: Replace the hardcoded validation step "make -C elixir all" in
      .codex/skills/push/SKILL.md with the repository's documented pnpm-based
      validation command from AGENTS.md (see AGENTS.md:41-96) so contributors run the
      canonical checks; update both occurrences of the string "make -C elixir all"
      (around the current lines showing that text) to the exact pnpm validation
      command used in AGENTS.md and ensure the wording references running the repo
      validation (not an Elixir-specific make target).
    • Around line 89-104: The PR body draft in tmp_pr_body should be passed to gh pr
      create/edit via the --body-file flag before running the linter; update the flow
      so that gh pr create (when pr_state empty) and gh pr edit (when pr_state set)
      both include --body-file "$tmp_pr_body" (using the same tmp_pr_body used later)
      and only after that invoke (cd elixir && mix pr_body.check --file
      "$tmp_pr_body"); ensure tmp_pr_body is populated with the drafted template
      (replace or skip gh pr view > "$tmp_pr_body" which validates the existing remote
      body).

    In @ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js:

    • Around line 58-59: The generic settings serializer currently includes the
      OAuth long-lived credentials 'x_access_token' and 'x_access_token_secret' in
      EDITABLE_SETTINGS, which allows writes outside the dedicated X endpoints; remove
      those two entries from EDITABLE_SETTINGS in the settings serializer so OAuth
      tokens are only writable via the dedicated /x endpoints (leave isSecretSetting
      as-is to continue treating them as secrets and do not add alternate validation
      here).

    In
    @ghost/core/core/server/data/migrations/versions/6.29/2026-04-13-00-12-50-add-x-auto-posting.js:

    • Around line 45-53: When inserting X token rows in the migration (the await
      connection('settings').insert({...}) block), ensure rows with setting.key ===
      'x_access_token' or 'x_access_token_secret' have their flags set to mark them
      secret (e.g. flags: 'secret') instead of null so
      settingsBREADService._formatBrowse() will mask them and /x/verify/ won't return
      raw tokens; update the insert logic to conditionally set flags based on the key
      before calling insert.

    In @skills-lock.json:

    • Around line 24-52: The skills-lock.json entries for "commit", "land",
      "linear", "pull", and "push" point to the remote repo odysseus0/symphony which
      will override the local skill code edited in this PR; regenerate or update
      skills-lock.json so those keys resolve to the local implementations changed in
      .agents/skills by either removing/replacing the "source"/"sourceType" with the
      local reference or updating the "computedHash" values to match the local files,
      then commit the regenerated lockfile (use your project's skill-lock generation
      command/tool to ensure consistency).

    In @WORKFLOW.md:

    • Around line 69-77: Replace the sentence that instructs the agent to "stop and
      ask the user to configure Linear" under the "Prerequisite: linear_graphql tool
      is available" section with an instruction to treat a missing linear_graphql
      tool as a blocked-access condition: log the blockage in the workpad, move the
      issue according to the existing blocked-access workflow, and continue without
      prompting the user for configuration; ensure the revised text references the
      linear_graphql prerequisite and the workpad/blocked-access handling so the
      agent follows the documented blocked path instead of asking humans to set up
      Linear.
    • Around line 21-23: The after_create hook hardcodes a machine-specific repo
      path in the git clone command (git clone --depth 1
      file:///Users/doronkatz/Development/Ghost .) which will fail on other runners;
      change after_create to read the repository URL from configuration or environment
      (e.g., an env var like BOOTSTRAP_REPO or derive it from the current git remote)
      and use that variable in the git clone command so the clone is not tied to
      /Users/doronkatz/Development/Ghost and works across CI/workspaces.

    Minor comments:
    In @.agents/skills/land/land_watch.py:

    • Around line 577-582: The watcher now raises SystemExit(5) when
      is_merge_conflicting(pr) in land_watch.py (also the same pattern at the second
      occurrence around the other raise), so update .agents/skills/land/SKILL.md to
      document exit code 5 with a brief description (e.g., "exit code 5 — PR has merge
      conflicts; resolve/rebase against main and push") and ensure callers that rely
      on documented outcomes know to treat 5 as a handled/expected state; verify both
      occurrences of SystemExit(5) are covered in the documentation.

    In @.agents/skills/land/SKILL.md:

    • Around line 110-115: Update the Exit codes list in SKILL.md to include the
      missing code 5: add an entry such as "- 5: PR merge-conflicting (watcher
      detected merge conflict)" so the table documents the watcher exit when a PR is
      merge-conflicting; modify the existing Exit codes section (the list containing
      codes 2, 3, 4) to insert this new line and keep formatting consistent with the
      other entries.

    In @.agents/skills/pull/SKILL.md:

    • Line 24: Update the SKILL.md to document the minimum Git version required for
      the zdiff3 conflict style: state that Git 2.35+ is required (zdiff3 introduced
      Jan 2022) and add this to a "Prerequisites" section; reference the existing
      mention of merge.conflictstyle=zdiff3 so readers see the requirement, and
      optionally add guidance for a fallback merge strategy or configuration for older
      Git versions (e.g., alternate conflict style) so the skill can run reliably on
      older environments.

    In @.codex/skills/commit/SKILL.md:

    • Around line 60-75: The fenced code block for the commit-message template is
      missing a language identifier causing markdownlint MD040; update the opening
      triple-backtick of the example block (the block that starts with and contains the commit template with "Summary:", "Rationale:", "Tests:", and "Co-authored-by:") to include a language such as "text" (i.e., change the opening to ```text) so the fence is typed and MD040 is resolved.

    In @.codex/skills/land/SKILL.md:

    • Around line 110-115: The Exit codes table is missing an entry for the watcher
      exit code 5; update the "Exit codes:" list in SKILL.md to include "5: PR
      merge-conflicting" (or similar wording like "5: PR merge-conflicting (merge
      conflict detected)") so callers mapping exit codes handle the conflict path;
      ensure the new entry follows the existing format alongside 2/3/4 in the same
      section.

    In @apps/admin-x-settings/test/acceptance/advanced/integrations/x.test.ts:

    • Around line 101-104: The assertion on lastApiRequests.disconnectX races
      because it's set asynchronously after the confirmation click; after calling
      xModal.getByRole(...).click() and
      page.getByTestId('confirmation-modal').getByRole(...).click(), wait for
      lastApiRequests.disconnectX to be populated before asserting (e.g., use a short
      polling loop or test helper like waitFor that repeatedly checks
      lastApiRequests.disconnectX until truthy, or await a page.waitForResponse
      matching the X disconnect endpoint), then assert
      expect(lastApiRequests.disconnectX).toBeTruthy(); reference the existing symbols
      lastApiRequests.disconnectX, xModal.getByRole, and
      page.getByTestId('confirmation-modal').getByRole when implementing the wait.

    In @ghost/admin/tests/acceptance/editor-test.js:

    • Around line 388-391: The assertion for xPostEnabled is too loose (it allows
      undefined/null); update the expectation on
      this.server.db.posts.find(post.id).xPostEnabled to assert true explicitly (use
      expect(...).to.equal(true)) or alternatively assert the UI checkbox state tied
      to xPostEnabled to ensure the default is serialized correctly; adjust the test
      in editor-test.js where the current expect(...).to.not.equal(false) is used so
      it verifies a concrete true value.

    In
    @ghost/core/core/server/data/migrations/versions/6.29/2026-04-13-00-12-50-add-x-auto-posting.js:

    • Around line 56-70: The down() migration currently deletes any matching x_* key
      unconditionally; change it to only remove settings that this migration actually
      created by comparing the existing row's value to the expected value from
      X_SETTINGS before deleting. In the down function, for each setting in X_SETTINGS
      check existingSetting.value === setting.value (or another unique marker set by
      up) and only call .del() when it matches; otherwise log and skip to avoid
      removing pre-existing/manual settings.

    Nitpick comments:
    In @.agents/skills/pull/SKILL.md:

    • Around line 23-26: Update the SKILL.md step that uses "git pull --ff-only" to
      include error-handling guidance: detect a non-zero exit from git pull --ff-only,
      run git fetch and inspect divergence with git status or git log --left-right
      --graph origin/$(git branch --show-current)...HEAD, then describe safe recovery
      options (rebase onto origin, merge, or reset --hard if force-pushed) and remind
      to back up local changes (stash or create a temp branch) before destructive
      commands; reference the existing step text ("git pull --ff-only origin $(git
      branch --show-current)") so reviewers can insert a short paragraph explaining
      these diagnostics and the recommended actions.
    • Around line 28-29: Update SKILL.md to document that the git
      merge.conflictstyle value "zdiff3" requires Git 2.35+ (note that it was verified
      with 2.39+) and add a fallback suggestion using "diff3" for older Git versions;
      specifically mention the example command shown (git -c merge.conflictstyle=zdiff3 merge origin/main) and provide the alternative (git -c merge.conflictstyle=diff3 merge origin/main) so users without zdiff3 know
      which command to run or to upgrade Git.

    In
    @apps/admin-x-settings/src/components/settings/advanced/integrations/x-modal.tsx:

    • Around line 6-21: The code currently forces settings to string via
      getSettingValues(...) and then derives isConnected from
      xAccessToken/xUsername, which can mask null values; change the call to
      getSettingValues without the generic and treat the returned
      xAccessToken and xUsername as possibly null/unknown, perform runtime typeof
      checks (e.g. typeof xAccessToken === 'string' && typeof xUsername === 'string')
      before computing isConnected, and apply sensible defaults or null-handling where
      xAccessToken, xUsername, or isConnected are used (refer to getSettingValues,
      xAccessToken, xUsername, and isConnected to locate and update the logic).

    In
    @ghost/core/test/unit/server/api/endpoints/utils/serializers/output/posts.test.js:

    • Around line 13-16: Replace the current multiple postsMapper.set calls with
      a single call that captures the revert function (e.g., const revert =
      postsMapper.set({url: ..., extraAttrs: ..., clean: ..., utils: ...})), then
      ensure you call revert() in a finally block (alongside sinon.restore()) so the
      module-level bindings are restored after the test; target the setup where
      postsMapper.set is currently used and wrap the test execution in try/finally
      to call revert() to avoid polluting other tests.
    
    </details>
    
    <details>
    <summary>🪄 Autofix (Beta)</summary>
    
    Fix all unresolved CodeRabbit comments on this PR:
    
    - [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
    - [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes
    
    </details>
    
    ---
    
    <details>
    <summary>ℹ️ Review info</summary>
    
    <details>
    <summary>⚙️ Run configuration</summary>
    
    **Configuration used**: Path: .coderabbit.yaml
    
    **Review profile**: CHILL
    
    **Plan**: Pro
    
    **Run ID**: `86be2e52-2db4-483b-b64c-97a93ecd8ebe`
    
    </details>
    
    <details>
    <summary>📥 Commits</summary>
    
    Reviewing files that changed from the base of the PR and between b5c0a92cc8e8ba3cb546590d9d6c8b113319db0c and 4b1497b668985c8cd233358731f8d3bcbd7be6e6.
    
    </details>
    
    <details>
    <summary>📒 Files selected for processing (51)</summary>
    
    * `.agents/skills/commit/SKILL.md`
    * `.agents/skills/debug/SKILL.md`
    * `.agents/skills/land/SKILL.md`
    * `.agents/skills/land/land_watch.py`
    * `.agents/skills/launch-app/SKILL.md`
    * `.agents/skills/linear/SKILL.md`
    * `.agents/skills/pull/SKILL.md`
    * `.agents/skills/push/SKILL.md`
    * `.codex/skills/commit/SKILL.md`
    * `.codex/skills/debug/SKILL.md`
    * `.codex/skills/land/SKILL.md`
    * `.codex/skills/land/land_watch.py`
    * `.codex/skills/linear/SKILL.md`
    * `.codex/skills/pull/SKILL.md`
    * `.codex/skills/push/SKILL.md`
    * `WORKFLOW.md`
    * `apps/admin-x-framework/src/api/settings.ts`
    * `apps/admin-x-framework/src/test/responses/settings.json`
    * `apps/admin-x-settings/src/components/providers/routing/modals.tsx`
    * `apps/admin-x-settings/src/components/providers/settings-router.tsx`
    * `apps/admin-x-settings/src/components/settings/advanced/integrations.tsx`
    * `apps/admin-x-settings/src/components/settings/advanced/integrations/x-modal.tsx`
    * `apps/admin-x-settings/test/acceptance/advanced/integrations/x.test.ts`
    * `ghost/admin/app/components/gh-post-settings-menu.hbs`
    * `ghost/admin/app/components/gh-post-settings-menu.js`
    * `ghost/admin/app/models/post.js`
    * `ghost/admin/app/serializers/page.js`
    * `ghost/admin/tests/acceptance/editor-test.js`
    * `ghost/core/core/boot.js`
    * `ghost/core/core/server/api/endpoints/index.js`
    * `ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js`
    * `ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js`
    * `ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js`
    * `ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/pages.js`
    * `ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js`
    * `ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js`
    * `ghost/core/core/server/api/endpoints/x.js`
    * `ghost/core/core/server/data/migrations/versions/6.29/2026-04-13-00-12-50-add-x-auto-posting.js`
    * `ghost/core/core/server/data/schema/default-settings/default-settings.json`
    * `ghost/core/core/server/data/schema/schema.js`
    * `ghost/core/core/server/models/posts-meta.js`
    * `ghost/core/core/server/services/posts/posts-service.js`
    * `ghost/core/core/server/services/settings/settings-utils.js`
    * `ghost/core/core/server/services/x.js`
    * `ghost/core/core/server/web/api/endpoints/admin/routes.js`
    * `ghost/core/core/shared/config/defaults.json`
    * `ghost/core/test/unit/server/api/endpoints/utils/serializers/output/posts.test.js`
    * `ghost/core/test/unit/server/services/posts/posts-service.test.js`
    * `ghost/core/test/unit/server/services/settings/settings-utils.test.js`
    * `ghost/core/test/unit/server/services/x.test.js`
    * `skills-lock.json`
    
    </details>
    
    </details>
    
    <!-- This is an auto-generated comment by CodeRabbit for review status -->
    

Comment on lines +29 to +34
3. Stage intended changes, including new files (`git add -A`) after confirming
scope.
4. Sanity-check newly added files; if anything looks random or likely ignored
(build artifacts, logs, temp files), flag it to the user before committing.
5. If staging is incomplete or includes unrelated files, fix the index or ask
for confirmation.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid git add -A in a selective commit workflow.

git add -A sweeps every tracked deletion and untracked file in the repo into the index. For a skill that is supposed to commit only the intended work, that's too easy to turn into an unrelated mixed commit.

Safer wording
-3. Stage intended changes, including new files (`git add -A`) after confirming
-   scope.
+3. Stage only the intended paths after confirming scope. Prefer explicit
+   `git add <path>...` (or `git add -p` when needed) so unrelated repo changes
+   are not swept into the commit.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/commit/SKILL.md around lines 29 - 34, Replace the risky
instruction that tells users to "Stage intended changes, including new files
(git add -A)" with guidance to stage only intended changes—e.g., recommend
interactive or path-specific staging (replace the phrase "git add -A" in that
line) and update surrounding steps so step 4 instructs checking staged changes
(git status/git diff --staged) before committing and step 5 advises amending the
index or asking for confirmation if unrelated files are present; ensure the
SKILL.md text explicitly suggests safer alternatives to git add -A and clarifies
the workflow for selective commits.

Comment on lines +53 to +55
# 3) Pull session IDs seen for that ticket
rg -o "session_id=[^ ;]+" log/symphony.log* | sort -u

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Filter session IDs through the ticket-specific log slice.

The prose says “session IDs seen for that ticket”, but this command scans every rotated log and can surface unrelated sessions. Reuse the ticket-filtered matches here before extracting IDs.

🛠️ Proposed fix
 # 3) Pull session IDs seen for that ticket
-rg -o "session_id=[^ ;]+" log/symphony.log* | sort -u
+rg -n "issue_identifier=MT-625" log/symphony.log* \
+  | rg -o "session_id=[^ ;]+" \
+  | sort -u
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 3) Pull session IDs seen for that ticket
rg -o "session_id=[^ ;]+" log/symphony.log* | sort -u
# 3) Pull session IDs seen for that ticket
rg -n "issue_identifier=MT-625" log/symphony.log* \
| rg -o "session_id=[^ ;]+" \
| sort -u
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/debug/SKILL.md around lines 53 - 55, The current rg command
(rg -o "session_id=[^ ;]+" log/symphony.log*) scans all logs and can return
unrelated sessions; change it to pipe from the ticket-specific filtered output
instead of scanning log/symphony.log* directly — i.e., reuse the prior
ticket-filtered match stream (the earlier rg/filter that narrows to the ticket)
as the input and then extract session IDs by applying the session_id regex (the
"session_id=[^ ;]+" pattern) and sort -u; update the step that references rg -o
"session_id=[^ ;]+" to operate on the ticket-filtered lines rather than all log
files.

Comment on lines +306 to +327
def filter_human_issue_comments(comments: list[dict[str, Any]]) -> list[dict[str, Any]]:
latest_ack = latest_codex_issue_reply_time(comments)
filtered: list[dict[str, Any]] = []
for comment in comments:
if is_bot_user(comment.get("user", {})):
continue
body = (comment.get("body") or "").strip()
if is_codex_reply_body(body):
continue
if is_codex_review_body(body):
continue
if "@codex review" in body:
continue
created_time = comment_time(comment)
if (
latest_ack is not None
and created_time is not None
and created_time <= latest_ack
):
continue
filtered.append(comment)
return filtered
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset feedback gating at the newest @codex review request.

latest_review_request_at() is computed, but the human issue-comment, human review-comment, and review-state filters never use it. That means unresolved feedback from an older review cycle can still trigger SystemExit(2) after a fresh @codex review, which defeats the “only consider activity after the latest request” behavior described for this watcher.

🛠️ Proposed fix
 def filter_human_issue_comments(
     comments: list[dict[str, Any]],
+    review_requested_at: datetime | None,
 ) -> list[dict[str, Any]]:
@@
         created_time = comment_time(comment)
+        if (
+            review_requested_at is not None
+            and created_time is not None
+            and created_time <= review_requested_at
+        ):
+            continue
         if (
             latest_ack is not None
             and created_time is not None
             and created_time <= latest_ack
         ):
             continue

Apply the same cutoff in filter_codex_review_issue_comments(), filter_human_review_comments(), and is_blocking_review() before raise_on_human_feedback() evaluates them.

Also applies to: 330-347, 379-398, 401-466, 488-511

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/land/land_watch.py around lines 306 - 327, The code computes
a latest review-request cutoff (latest_review_request_at) but never applies it
when evaluating human feedback, so older unresolved feedback can still trigger
raise_on_human_feedback; update filter_human_issue_comments and also apply the
same cutoff in filter_codex_review_issue_comments, filter_human_review_comments,
and is_blocking_review so they ignore comments/reviews with comment_time() (or
created_time) <= latest_review_request_at() before raise_on_human_feedback is
called; ensure you reference and use latest_review_request_at() (or pass
latest_ack) as the gating value and skip items earlier than that in those
functions (e.g., inside filter_human_issue_comments,
filter_codex_review_issue_comments, filter_human_review_comments, and
is_blocking_review) so only activity after the most recent "@codex review" is
considered.

Comment on lines +29 to +30
2. Run local validation (`make -C elixir all`) before pushing.
3. Push branch to `origin` with upstream tracking if needed, using whatever
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Checking for an elixir workspace:"
fd -HI '^elixir$' -td .

echo
echo "Checking for pr_body.check / related Mix task definitions:"
rg -n --hidden -S 'pr_body\.check|Mix\.Tasks\..*PrBody|defmodule\s+Mix\.Tasks\.[A-Za-z0-9_.]*PrBody' .

Repository: TryGhost/Ghost

Length of output: 545


🏁 Script executed:

cat -n .agents/skills/push/SKILL.md | head -120

Repository: TryGhost/Ghost

Length of output: 5271


🏁 Script executed:

# Double-check for any elixir directory at any location, not just root
find . -type d -name elixir 2>/dev/null | head -20

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check if there's any .gitignore or similar that might hide it
git ls-files | grep -i elixir || echo "No elixir files in git index"

Repository: TryGhost/Ghost

Length of output: 86


Remove or conditionally gate the hardcoded Elixir validation commands, as the elixir/ workspace does not exist in this repository.

Lines 29, 65, and 104 assume a make -C elixir all target and mix pr_body.check Mix task that are not present in this repo. The skill will fail at execution:

  • Line 65: make -C elixir all will fail (no elixir/ directory)
  • Line 104: (cd elixir && mix pr_body.check ...) will fail (directory and task missing)

Either remove these validation steps, replace them with actual repository validation steps, or add a guard that skips them gracefully if the workspace is absent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/push/SKILL.md around lines 29 - 30, The SKILL.md includes
hardcoded Elixir validation commands ("make -C elixir all" and "(cd elixir &&
mix pr_body.check ...)") that will fail because there is no elixir/ workspace;
remove these commands or wrap them with a presence check: either replace them
with the repository's actual validation steps or add a conditional gate that
detects the elixir/ directory and the mix task before invoking "make -C elixir
all" or "mix pr_body.check" so the push skill skips those steps gracefully when
the workspace or task is absent.

Comment on lines +47 to +55
6. Write/update PR body explicitly using `.github/pull_request_template.md`:
- Fill every section with concrete content for this change.
- Replace all placeholder comments (`<!-- ... -->`).
- Keep bullets/checkboxes where template expects them.
- If PR already exists, refresh body content so it reflects the total PR
scope (all intended work on the branch), not just the newest commits,
including newly added work, removed work, or changed approach.
- Do not reuse stale description text from earlier iterations.
7. Validate PR body with `mix pr_body.check` and fix all reported issues.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The PR body flow is still a no-op.

This points to .github/pull_request_template.md, but the template here is .github/PULL_REQUEST_TEMPLATE.md. The command block also never passes a body file to gh pr create/edit; it only validates whatever body already exists. New PRs can therefore open with empty/stale descriptions and the documented template never gets applied.

Possible fix
-6. Write/update PR body explicitly using `.github/pull_request_template.md`:
+6. Write/update PR body explicitly using `.github/PULL_REQUEST_TEMPLATE.md`:
@@
-# Write/edit PR body to match .github/pull_request_template.md before validation.
+# Write/edit PR body to match .github/PULL_REQUEST_TEMPLATE.md before validation.
 # Example workflow:
 # 1) open the template and draft body content for this PR
-# 2) gh pr edit --body-file /tmp/pr_body.md
+# 2) gh pr create/edit --body-file /tmp/pr_body.md
 # 3) for branch updates, re-check that title/body still match current diff

Also applies to: 96-105

🧰 Tools
🪛 LanguageTool

[uncategorized] ~47-~47: The official name of this software platform is spelled with a capital “H”.
Context: .... Write/update PR body explicitly using .github/pull_request_template.md: - Fill ev...

(GITHUB)


[style] ~55-~55: Consider using a different verb for a more formal wording.
Context: ...te PR body with mix pr_body.check and fix all reported issues. 8. Reply with the ...

(FIX_RESOLVE)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/push/SKILL.md around lines 47 - 55, The documented PR-body
flow is a no-op because the template path and GH CLI invocation are wrong:
update references from `.github/pull_request_template.md` to the actual file
`.github/PULL_REQUEST_TEMPLATE.md` (or rename the template to the referenced
lowercase name) and modify the gh command used in the workflow/script so it
passes the template as the body file (use gh pr create/edit --body-file
<template> or the equivalent flag your CLI version supports) instead of only
validating the existing body; also ensure `mix pr_body.check` is pointed at the
correct template path or updated to validate the file that will actually be
supplied to gh.

Comment on lines +58 to +59
'x_access_token',
'x_access_token_secret',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Definitions and usages of the X settings keys"
rg -n "\b(x_access_token|x_access_token_secret|x_user_id|x_username)\b" ghost/core apps

echo
echo "Secret/obfuscation handling in settings serialization/services"
rg -n "isSecretSetting|obfuscatedSetting|obfuscat|secret" ghost/core/core/server/services ghost/core/core/server/api

echo
echo "Settings writers touching the X keys"
rg -n "Settings\.edit|settingsBREADService\.edit|models\.Settings\.edit" ghost/core apps

Repository: TryGhost/Ghost

Length of output: 30158


Remove OAuth token credentials from generic settings edit path.

Lines 58–59 make the long-lived X credentials writable through the generic non-internal settings serializer, in addition to the dedicated /x endpoints. Since these keys are already handled by dedicated X endpoints that presumably enforce OAuth flow validation, including them in EDITABLE_SETTINGS widens the write surface unnecessarily.

Although isSecretSetting correctly identifies these as secrets (the regex matches "access_token"), allowing writes through two paths still creates an additional attack vector that bypasses the validation logic in the dedicated endpoints.

Suggested change
-    'x_access_token',
-    'x_access_token_secret',
     'x_user_id',
     'x_username',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js`
around lines 58 - 59, The generic settings serializer currently includes the
OAuth long-lived credentials 'x_access_token' and 'x_access_token_secret' in
EDITABLE_SETTINGS, which allows writes outside the dedicated X endpoints; remove
those two entries from EDITABLE_SETTINGS in the settings serializer so OAuth
tokens are only writable via the dedicated /x endpoints (leave isSecretSetting
as-is to continue treating them as secrets and do not add alternate validation
here).

Comment on lines +45 to +53
await connection('settings').insert({
id: ObjectId().toHexString(),
key: setting.key,
value: setting.value,
group: setting.group,
type: setting.type,
flags: null,
created_at: connection.raw('CURRENT_TIMESTAMP')
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mark the X token settings as secret before inserting them.

settingsBREADService._formatBrowse() only masks secret settings, and /x/verify/ returns a fresh settings browse response right after these rows are saved. With flags: null, x_access_token and x_access_token_secret will be exposed back to the admin client as raw values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/data/migrations/versions/6.29/2026-04-13-00-12-50-add-x-auto-posting.js`
around lines 45 - 53, When inserting X token rows in the migration (the await
connection('settings').insert({...}) block), ensure rows with setting.key ===
'x_access_token' or 'x_access_token_secret' have their flags set to mark them
secret (e.g. flags: 'secret') instead of null so
settingsBREADService._formatBrowse() will mask them and /x/verify/ won't return
raw tokens; update the insert logic to conditionally set flags based on the key
before calling insert.

Comment on lines 24 to +52
"commit": {
"source": "./.agents",
"sourceType": "local",
"computedHash": "f2f9a52e210a32ad42d4bf5826ce7cec265a2c472de03bee3840a5b295d734d7"
"source": "odysseus0/symphony",
"sourceType": "github",
"computedHash": "9384add5572ef41749c7b24dbff7131db267506aff3bce7ff7c2effe4ec0e7f8"
},
"debug": {
"source": "odysseus0/symphony",
"sourceType": "github",
"computedHash": "9ba57239460065b1acf67d80293b1332e26077cab5dc0047867ad53949fff97d"
},
"land": {
"source": "odysseus0/symphony",
"sourceType": "github",
"computedHash": "b2a4891b2454d35e300620ef3c44e444f64c207c59fbbfe9a1d1af2b01fc1a1a"
},
"linear": {
"source": "odysseus0/symphony",
"sourceType": "github",
"computedHash": "50a8e09b2223e9b958e780a877a64e5b6f0b225650e14714cf94847987837086"
},
"pull": {
"source": "odysseus0/symphony",
"sourceType": "github",
"computedHash": "f32735dab559a59353bcb5855065c6dce991f4f31b3dbe2925493842e9aa324f"
},
"push": {
"source": "odysseus0/symphony",
"sourceType": "github",
"computedHash": "b9ace137b2f25f73887e6cee47b91b548f300ea771a41a81999b02a7ce6c12a9"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This lockfile now points away from the local skills changed in the same PR.

commit, land, linear, pull, and push now resolve from odysseus0/symphony, while this PR edits the corresponding local implementations under .agents/skills/.... If the resolver honors skills-lock.json, it will fetch the remote versions and ignore the files modified here until that external repo is updated to the same hashes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills-lock.json` around lines 24 - 52, The skills-lock.json entries for
"commit", "land", "linear", "pull", and "push" point to the remote repo
odysseus0/symphony which will override the local skill code edited in this PR;
regenerate or update skills-lock.json so those keys resolve to the local
implementations changed in .agents/skills by either removing/replacing the
"source"/"sourceType" with the local reference or updating the "computedHash"
values to match the local files, then commit the regenerated lockfile (use your
project's skill-lock generation command/tool to ensure consistency).

Comment on lines +21 to +23
after_create: |
git clone --depth 1 file:///Users/doronkatz/Development/Ghost .
/bin/zsh -lc 'cd "$PWD" && corepack enable pnpm && pnpm run setup'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Parameterize the bootstrap repo path.

file:///Users/doronkatz/Development/Ghost is machine-specific, so any other runner will fail during workspace creation. This needs to come from config/env or the current repo remote instead of a developer home directory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@WORKFLOW.md` around lines 21 - 23, The after_create hook hardcodes a
machine-specific repo path in the git clone command (git clone --depth 1
file:///Users/doronkatz/Development/Ghost .) which will fail on other runners;
change after_create to read the repository URL from configuration or environment
(e.g., an env var like BOOTSTRAP_REPO or derive it from the current git remote)
and use that variable in the git clone command so the clone is not tied to
/Users/doronkatz/Development/Ghost and works across CI/workspaces.

Comment on lines +69 to +77
1. This is an unattended orchestration session. Never ask a human to perform follow-up actions.
2. Only stop early for a true blocker (missing required auth/permissions/secrets). If blocked, record it in the workpad and move the issue according to workflow.
3. Final message must report completed actions and blockers only. Do not include "next steps for user".

Work only in the provided repository copy. Do not touch any other path.

## Prerequisite: `linear_graphql` tool is available

The agent talks to Linear via the `linear_graphql` tool injected by Symphony's app-server. If it is not present, stop and ask the user to configure Linear. Do not use a Linear MCP server - it returns full JSON payloads that waste tokens. Use `linear_graphql` with narrowly scoped queries instead.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't tell an unattended session to ask the user for Linear setup.

Line 69 says this workflow never asks humans for follow-up, but Line 77 instructs the agent to stop and ask the user to configure Linear. In the missing-tool case that creates a dead end instead of following the blocked-access path later in the document.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@WORKFLOW.md` around lines 69 - 77, Replace the sentence that instructs the
agent to "stop and ask the user to configure Linear" under the "Prerequisite:
`linear_graphql` tool is available" section with an instruction to treat a
missing `linear_graphql` tool as a blocked-access condition: log the blockage in
the workpad, move the issue according to the existing blocked-access workflow,
and continue without prompting the user for configuration; ensure the revised
text references the `linear_graphql` prerequisite and the workpad/blocked-access
handling so the agent follows the documented blocked path instead of asking
humans to set up Linear.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

migration [pull request] Includes migration for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant