Skip to content

Commit 8466560

Browse files
DCjanuscodex
andauthored
feat(confluence-cli): 改进 Markdown 发布与附件处理 (#35)
## Why - 当前 `confluence-cli` 的发布链路对 Confluence Data Center / Server 的行为控制不够直接,附件更新和页面更新流程也不够稳定。 - 需要补齐 Markdown 到 Confluence storage 的转换覆盖,并补充相关回归测试。 ## What - 将 `confluence_api_client` 改为基于 `httpx` 的 REST 封装,显式处理认证、页面版本递增、附件创建与附件更新。 - 重写 `publish-markdown` 的 Markdown 转换流程,基于 `markdown-it-py` 支持列表、表格、围栏代码块、本地附件链接和本地图片,并在附件上传后回填最终链接。 - 新增 `confluence_api_client` 与 `markdown_to_storage` 测试,并补充 `create-skill` / `gitlab-cli` 的文档说明。 ## Testing - `uv run --with httpx --with markdown-it-py --with pydantic --with rich --with typer python -m unittest skills.confluence-cli.scripts.tests.test_confluence_api_client skills.confluence-cli.scripts.tests.test_markdown_to_storage` ## Risks - Breaking change: `publish-markdown` 现在仅支持 `storage`,若仍传入其他 `body_format` 会直接报错,需要同步调整调用方式。 --------- Co-authored-by: OpenAI Codex <codex@openai.com>
1 parent 7efb0d2 commit 8466560

File tree

10 files changed

+719
-188
lines changed

10 files changed

+719
-188
lines changed
Lines changed: 158 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
#!/usr/bin/env -S uv run --script
2-
# /// script
3-
# requires-python = ">=3.14"
4-
# dependencies = [
5-
# "atlassian-python-api>=4.0.7",
6-
# "pydantic>=2.12.5",
7-
# ]
8-
# ///
9-
"""Confluence API 客户端封装。"""
1+
"""Confluence REST API 客户端封装。"""
102

113
from __future__ import annotations
124

5+
import base64
6+
from pathlib import Path
137
from typing import Any
148

15-
from atlassian import Confluence
9+
import httpxyz
1610
from pydantic import BaseModel, Field
1711

1812
DEFAULT_TIMEOUT_SECONDS = 30.0
@@ -21,10 +15,16 @@
2115
class ConfluenceApiError(RuntimeError):
2216
"""Confluence API 错误。"""
2317

24-
def __init__(self, message: str, status_code: int | None = None) -> None:
25-
"""初始化 API 错误。"""
18+
def __init__(
19+
self,
20+
message: str,
21+
*,
22+
status_code: int | None = None,
23+
payload: Any | None = None,
24+
) -> None:
2625
super().__init__(message)
2726
self.status_code = status_code
27+
self.payload = payload
2828

2929

3030
class ConfluenceConfig(BaseModel):
@@ -43,45 +43,98 @@ class ConfluenceConfig(BaseModel):
4343

4444

4545
class ConfluenceApiClient:
46-
"""Confluence API 客户端封装。"""
46+
"""Confluence REST API 薄封装。"""
4747

4848
def __init__(self, config: ConfluenceConfig) -> None:
49-
"""初始化 Confluence API 客户端。"""
5049
self.config = config
51-
kwargs: dict[str, Any] = {
52-
"url": config.base_url,
53-
"timeout": config.timeout_seconds,
54-
"verify_ssl": config.verify_ssl,
55-
}
56-
if config.cloud is not None:
57-
kwargs["cloud"] = config.cloud
50+
self.client = httpxyz.Client(
51+
base_url=config.base_url.rstrip("/") + "/",
52+
timeout=config.timeout_seconds,
53+
verify=config.verify_ssl,
54+
headers=self._build_headers(config),
55+
)
56+
57+
@staticmethod
58+
def _build_headers(config: ConfluenceConfig) -> dict[str, str]:
59+
headers = {"Accept": "application/json"}
5860
if config.username:
59-
kwargs["username"] = config.username
60-
kwargs["password"] = config.token
61+
raw = f"{config.username}:{config.token}".encode("utf-8")
62+
headers["Authorization"] = f"Basic {base64.b64encode(raw).decode('utf-8')}"
6163
else:
62-
kwargs["token"] = config.token
63-
self.client = Confluence(**kwargs)
64+
headers["Authorization"] = f"Bearer {config.token}"
65+
return headers
66+
67+
@staticmethod
68+
def _raise_for_error(response: httpxyz.Response, context: str) -> None:
69+
if response.is_success:
70+
return
71+
payload: Any
72+
try:
73+
payload = response.json()
74+
except ValueError:
75+
payload = response.text
76+
raise ConfluenceApiError(
77+
f"{context} failed with status {response.status_code}",
78+
status_code=response.status_code,
79+
payload=payload,
80+
)
81+
82+
@staticmethod
83+
def _encode_body(body: str, representation: str) -> dict[str, Any]:
84+
return {representation: {"value": body, "representation": representation}}
85+
86+
def _get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
87+
response = self.client.get(path, params=params)
88+
self._raise_for_error(response, f"GET {path}")
89+
return response.json()
90+
91+
def _post(
92+
self,
93+
path: str,
94+
*,
95+
json_data: dict[str, Any] | None = None,
96+
files: Any | None = None,
97+
headers: dict[str, str] | None = None,
98+
) -> Any:
99+
response = self.client.post(path, json=json_data, files=files, headers=headers)
100+
self._raise_for_error(response, f"POST {path}")
101+
return response.json()
102+
103+
def _put(
104+
self,
105+
path: str,
106+
*,
107+
json_data: dict[str, Any],
108+
params: dict[str, Any] | None = None,
109+
) -> Any:
110+
response = self.client.put(path, json=json_data, params=params)
111+
self._raise_for_error(response, f"PUT {path}")
112+
return response.json()
64113

65114
def list_spaces(self, start: int = 0, limit: int = 25, expand: str | None = None) -> Any:
66-
"""列出空间列表。"""
67-
return self.client.get_all_spaces(start=start, limit=limit, expand=expand)
115+
params: dict[str, Any] = {"start": start, "limit": limit}
116+
if expand:
117+
params["expand"] = expand
118+
return self._get("rest/api/space", params=params)
68119

69120
def get_space(self, space_key: str, expand: str | None = None) -> Any:
70-
"""获取空间详情。"""
71-
return self.client.get_space(space_key, expand=expand)
121+
params = {"expand": expand} if expand else None
122+
return self._get(f"rest/api/space/{space_key}", params=params)
72123

73124
def get_page(self, page_id: str, expand: str | None = None) -> Any:
74-
"""按页面 ID 获取页面。"""
75-
return self.client.get_page_by_id(page_id, expand=expand)
125+
params = {"expand": expand} if expand else None
126+
return self._get(f"rest/api/content/{page_id}", params=params)
76127

77128
def get_page_by_title(
78129
self,
79130
space_key: str,
80131
title: str,
81132
expand: str | None = None,
82133
) -> Any:
83-
"""按标题获取页面。"""
84-
return self.client.get_page_by_title(space_key, title, expand=expand)
134+
params: dict[str, Any] = {"spaceKey": space_key, "title": title}
135+
if expand:
136+
params["expand"] = expand
137+
return self._get("rest/api/content", params=params)
85138

86139
def get_page_children(
87140
self,
@@ -90,14 +143,10 @@ def get_page_children(
90143
limit: int = 25,
91144
expand: str | None = None,
92145
) -> Any:
93-
"""获取子页面列表。"""
94-
return self.client.get_page_child_by_type(
95-
page_id,
96-
type="page",
97-
start=start,
98-
limit=limit,
99-
expand=expand,
100-
)
146+
params: dict[str, Any] = {"start": start, "limit": limit}
147+
if expand:
148+
params["expand"] = expand
149+
return self._get(f"rest/api/content/{page_id}/child/page", params=params)
101150

102151
def get_page_attachments(
103152
self,
@@ -106,14 +155,10 @@ def get_page_attachments(
106155
limit: int = 25,
107156
expand: str | None = None,
108157
) -> Any:
109-
"""获取页面附件列表。"""
110158
params: dict[str, Any] = {"start": start, "limit": limit}
111159
if expand:
112160
params["expand"] = expand
113-
return self.client.get(
114-
f"rest/api/content/{page_id}/child/attachment",
115-
params=params,
116-
)
161+
return self._get(f"rest/api/content/{page_id}/child/attachment", params=params)
117162

118163
def create_page(
119164
self,
@@ -123,14 +168,15 @@ def create_page(
123168
parent_id: str | None = None,
124169
representation: str = "storage",
125170
) -> Any:
126-
"""创建页面。"""
127-
return self.client.create_page(
128-
space=space_key,
129-
title=title,
130-
body=body,
131-
parent_id=parent_id,
132-
representation=representation,
133-
)
171+
data: dict[str, Any] = {
172+
"type": "page",
173+
"title": title,
174+
"space": {"key": space_key},
175+
"body": self._encode_body(body, representation),
176+
}
177+
if parent_id:
178+
data["ancestors"] = [{"type": "page", "id": parent_id}]
179+
return self._post("rest/api/content", json_data=data)
134180

135181
def update_page(
136182
self,
@@ -139,15 +185,25 @@ def update_page(
139185
body: str,
140186
parent_id: str | None = None,
141187
representation: str = "storage",
188+
always_update: bool = False,
142189
) -> Any:
143-
"""更新页面。"""
144-
return self.client.update_page(
145-
page_id=page_id,
146-
title=title,
147-
body=body,
148-
parent_id=parent_id,
149-
representation=representation,
150-
)
190+
current = self.get_page(page_id, expand="version")
191+
version = current.get("version", {}).get("number")
192+
if not isinstance(version, int):
193+
raise ConfluenceApiError(f"Failed to resolve current page version for {page_id}")
194+
195+
data: dict[str, Any] = {
196+
"id": page_id,
197+
"type": "page",
198+
"title": title,
199+
"version": {"number": version + 1},
200+
"body": self._encode_body(body, representation),
201+
}
202+
if parent_id:
203+
data["ancestors"] = [{"type": "page", "id": parent_id}]
204+
if always_update:
205+
data["version"]["minorEdit"] = False
206+
return self._put(f"rest/api/content/{page_id}", json_data=data, params={"status": "current"})
151207

152208
def attach_file(
153209
self,
@@ -156,13 +212,28 @@ def attach_file(
156212
title: str | None = None,
157213
comment: str | None = None,
158214
) -> Any:
159-
"""上传附件到页面。"""
160-
return self.client.attach_file(
161-
filename=file_path,
162-
page_id=page_id,
163-
title=title,
164-
comment=comment,
165-
)
215+
path = Path(file_path)
216+
if not path.exists():
217+
raise ConfluenceApiError(f"Attachment file not found: {file_path}")
218+
filename = title or path.name
219+
existing_attachment_id = self._find_attachment_id(page_id, filename)
220+
with path.open("rb") as file_obj:
221+
files = {
222+
"file": (filename, file_obj, "application/octet-stream"),
223+
"minorEdit": (None, "true"),
224+
}
225+
if comment:
226+
files["comment"] = (None, comment)
227+
target_path = (
228+
f"rest/api/content/{page_id}/child/attachment/{existing_attachment_id}/data"
229+
if existing_attachment_id
230+
else f"rest/api/content/{page_id}/child/attachment"
231+
)
232+
return self._post(
233+
target_path,
234+
files=files,
235+
headers={"X-Atlassian-Token": "no-check"},
236+
)
166237

167238
def search_cql(
168239
self,
@@ -171,5 +242,21 @@ def search_cql(
171242
limit: int = 25,
172243
expand: str | None = None,
173244
) -> Any:
174-
"""执行 CQL 搜索。"""
175-
return self.client.cql(cql, start=start, limit=limit, expand=expand)
245+
params: dict[str, Any] = {"cql": cql, "start": start, "limit": limit}
246+
if expand:
247+
params["expand"] = expand
248+
return self._get("rest/api/search", params=params)
249+
250+
def _find_attachment_id(self, page_id: str, filename: str) -> str | None:
251+
payload = self.get_page_attachments(page_id, start=0, limit=200)
252+
results = payload.get("results") if isinstance(payload, dict) else None
253+
if not isinstance(results, list):
254+
return None
255+
for item in results:
256+
if not isinstance(item, dict):
257+
continue
258+
if str(item.get("title", "")) == filename:
259+
attachment_id = item.get("id")
260+
if attachment_id is not None:
261+
return str(attachment_id)
262+
return None

0 commit comments

Comments
 (0)