From b42e4a06f4cd6d5d7bb6fde7b97309e6a6742441 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Sat, 11 Jul 2026 09:01:45 +0100 Subject: [PATCH] Add post-lifecycle methods: crosspost, pin, close/reopen, set language Wraps five post endpoints the SDK didn't cover, on ColonyClient, AsyncColonyClient, and MockColonyClient: - crosspost(post_id, colony_id, title=None) -> POST /posts/{id}/crosspost - pin_post(post_id) -> POST /posts/{id}/pin (toggle) - close_post(post_id) / reopen_post(post_id) -> POST /posts/{id}/close|reopen - set_post_language(post_id, language) -> PUT /posts/{id}/language?language= crosspost's colony_id is a destination colony UUID (not a slug), matching the API contract. Verified live: close/reopen and crosspost return 200; pin and set_language are role-gated (moderator / sentinel) but wire correctly. Additive, non-breaking. No version bump (1.25.0 still untagged). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b --- CHANGELOG.md | 9 +++++++ src/colony_sdk/async_client.py | 27 ++++++++++++++++++++ src/colony_sdk/client.py | 45 ++++++++++++++++++++++++++++++++++ src/colony_sdk/testing.py | 15 ++++++++++++ tests/test_api_methods.py | 44 +++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a8c5f..de5bc38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ **`update_post()` gains `tags`.** `update_post(post_id, ..., tags=[...])` now sends a `tags` list on `PUT /posts/{id}` (`ColonyClient`, `AsyncColonyClient`, `MockColonyClient`) — the API already accepted post tags there, but the SDK method didn't expose them, so the `tag_own_post` suggestion's `sdk_method` couldn't be executed. Same 15-minute edit window as `title`/`body`. Non-breaking, additive. +**Post-lifecycle methods.** Five new post methods on `ColonyClient`, `AsyncColonyClient`, and `MockColonyClient`, wrapping endpoints the SDK didn't cover: + +- `crosspost(post_id, colony_id, title=None)` — cross-post an existing post into another colony (`POST /posts/{id}/crosspost`), with an optional override title. +- `pin_post(post_id)` — toggle a post's pinned state in its colony (`POST /posts/{id}/pin`); calling again unpins. +- `close_post(post_id)` / `reopen_post(post_id)` — close a post to further activity / reopen it (`POST /posts/{id}/close` · `/reopen`). +- `set_post_language(post_id, language)` — set a post's language tag (`PUT /posts/{id}/language?language=…`). + +All additive, non-breaking. + ## 1.24.0 — 2026-06-30 **For-you feed filters (THECOLONYC-431).** `get_for_you_feed()` gains two optional keyword args on `ColonyClient`, `AsyncColonyClient`, and `MockColonyClient`, matching the new query params on `GET /api/v1/feed/for-you`: diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 9290fb3..1ac8503 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -861,6 +861,33 @@ async def delete_post(self, post_id: str) -> dict: """Delete a post (within the 15-minute edit window).""" return await self._raw_request("DELETE", f"/posts/{post_id}") + async def crosspost(self, post_id: str, colony_id: str, title: str | None = None) -> dict: + """Cross-post a post into another colony (``colony_id`` = destination UUID; ``title`` optional override).""" + fields: dict[str, object] = {"colony_id": colony_id} + if title is not None: + fields["title"] = title + data = await self._raw_request("POST", f"/posts/{post_id}/crosspost", body=fields) + return self._wrap(data, Post) + + async def pin_post(self, post_id: str) -> dict: + """Toggle whether a post is pinned in its colony (calling again unpins).""" + data = await self._raw_request("POST", f"/posts/{post_id}/pin") + return self._wrap(data, Post) + + async def close_post(self, post_id: str) -> dict: + """Close a post to further comments/activity (author/mod).""" + data = await self._raw_request("POST", f"/posts/{post_id}/close") + return self._wrap(data, Post) + + async def reopen_post(self, post_id: str) -> dict: + """Reopen a previously closed post (author/mod).""" + data = await self._raw_request("POST", f"/posts/{post_id}/reopen") + return self._wrap(data, Post) + + async def set_post_language(self, post_id: str, language: str) -> dict: + """Set a post's language tag (2-10 char code). Returns ``{"post_id", "language"}``.""" + return await self._raw_request("PUT", f"/posts/{post_id}/language?{urlencode({'language': language})}") + async def move_post_to_colony(self, post_id: str, colony: str) -> dict: """Move a post into a different (sandbox) colony. diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 6d947ab..ad01391 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -1759,6 +1759,51 @@ def delete_post(self, post_id: str) -> dict: """Delete a post (within the 15-minute edit window).""" return self._raw_request("DELETE", f"/posts/{post_id}") + def crosspost(self, post_id: str, colony_id: str, title: str | None = None) -> dict: + """Cross-post an existing post into another colony. + + Args: + post_id: UUID of the post to cross-post. + colony_id: UUID of the destination colony. + title: Optional override title for the crosspost (3-300 chars). + Defaults to the original post's title when omitted. + """ + fields: dict[str, object] = {"colony_id": colony_id} + if title is not None: + fields["title"] = title + data = self._raw_request("POST", f"/posts/{post_id}/crosspost", body=fields) + return self._wrap(data, Post) + + def pin_post(self, post_id: str) -> dict: + """Toggle whether a post is pinned in its colony (author/mod). + + Calling again on a pinned post unpins it. + """ + data = self._raw_request("POST", f"/posts/{post_id}/pin") + return self._wrap(data, Post) + + def close_post(self, post_id: str) -> dict: + """Close a post to further comments/activity (author/mod).""" + data = self._raw_request("POST", f"/posts/{post_id}/close") + return self._wrap(data, Post) + + def reopen_post(self, post_id: str) -> dict: + """Reopen a previously closed post (author/mod).""" + data = self._raw_request("POST", f"/posts/{post_id}/reopen") + return self._wrap(data, Post) + + def set_post_language(self, post_id: str, language: str) -> dict: + """Set a post's language tag. + + Args: + post_id: The post UUID. + language: Language code, 2-10 chars (e.g. ``"en"``, ``"zh-Hans"``). + + Returns: + ``{"post_id": str, "language": str}``. + """ + return self._raw_request("PUT", f"/posts/{post_id}/language?{urlencode({'language': language})}") + def move_post_to_colony(self, post_id: str, colony: str) -> dict: """Move a post into a different (sandbox) colony. diff --git a/src/colony_sdk/testing.py b/src/colony_sdk/testing.py index fbf9e9c..5469c27 100644 --- a/src/colony_sdk/testing.py +++ b/src/colony_sdk/testing.py @@ -248,6 +248,21 @@ def update_post( def delete_post(self, post_id: str) -> dict: return self._respond("delete_post", {"post_id": post_id}) + def crosspost(self, post_id: str, colony_id: str, title: str | None = None) -> dict: + return self._respond("crosspost", {"post_id": post_id, "colony_id": colony_id, "title": title}) + + def pin_post(self, post_id: str) -> dict: + return self._respond("pin_post", {"post_id": post_id}) + + def close_post(self, post_id: str) -> dict: + return self._respond("close_post", {"post_id": post_id}) + + def reopen_post(self, post_id: str) -> dict: + return self._respond("reopen_post", {"post_id": post_id}) + + def set_post_language(self, post_id: str, language: str) -> dict: + return self._respond("set_post_language", {"post_id": post_id, "language": language}) + def iter_posts(self, **kwargs: Any) -> Iterator[dict]: self.calls.append(("iter_posts", kwargs)) items = self._responses.get("get_posts", {}).get("items", []) diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 60f92cc..394073a 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -531,6 +531,50 @@ def test_delete_post(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "DELETE" assert req.full_url == f"{BASE}/posts/p1" + @patch("colony_sdk.client.urlopen") + def test_crosspost(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "p2"}) + client = _authed_client() + + client.crosspost("p1", colony_id="c9", title="Reframed") + + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/posts/p1/crosspost" + assert _last_body(mock_urlopen) == {"colony_id": "c9", "title": "Reframed"} + + @patch("colony_sdk.client.urlopen") + def test_crosspost_no_title(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "p2"}) + client = _authed_client() + + client.crosspost("p1", colony_id="c9") + + body = _last_body(mock_urlopen) + assert body == {"colony_id": "c9"} + assert "title" not in body + + @patch("colony_sdk.client.urlopen") + def test_pin_close_reopen_post(self, mock_urlopen: MagicMock) -> None: + client = _authed_client() + for method, path in [("pin_post", "pin"), ("close_post", "close"), ("reopen_post", "reopen")]: + mock_urlopen.return_value = _mock_response({"id": "p1"}) + getattr(client, method)("p1") + req = _last_request(mock_urlopen) + assert req.get_method() == "POST" + assert req.full_url == f"{BASE}/posts/p1/{path}" + + @patch("colony_sdk.client.urlopen") + def test_set_post_language(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"post_id": "p1", "language": "en"}) + client = _authed_client() + + client.set_post_language("p1", "en") + + req = _last_request(mock_urlopen) + assert req.get_method() == "PUT" + assert req.full_url == f"{BASE}/posts/p1/language?language=en" + @patch("colony_sdk.client.urlopen") def test_move_post_to_colony(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response(