@jira-deploy/mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,38 @@
1
+ # Example environment variables for jira-deploy-mcp
2
+ # Copy to .env and fill values for your environment
3
+
4
+
5
+ # General
6
+ DRY_RUN=true
7
+
8
+ # Jira 設定
9
+ JIRA_BASE_URL=https://your-org.atlassian.net
10
+ JIRA_USER_EMAIL=your-email@company.com
11
+ JIRA_API_TOKEN=your_jira_api_token
12
+
13
+ # 預設專案 key(可在開單時 override)
14
+ JIRA_PROJECT_KEY=OPS
15
+
16
+ # 輪詢設定
17
+ POLL_INTERVAL_MS=30000
18
+ POLL_TIMEOUT_MS=3600000
19
+
20
+ # Bitbucket 設定
21
+ BITBUCKET_URL=https://bitbucket.example.com
22
+ BITBUCKET_API_TOKEN=your_bitbucket_api_token
23
+
24
+ # Confluence
25
+ CONF_BASE_URL=https://confluence.example.com
26
+ CONF_TOKEN=your-confluence-token
27
+
28
+ # Jabber (XMPP)
29
+ # Prefer storing the password in macOS Keychain and leave JABBER_PASSWORD empty.
30
+ # Set JABBER_PASSWORD only for CI or quick overrides.
31
+ JABBER_SERVER=10.244.192.137
32
+ JABBER_USER=your_jabber_account_number # e.g., BK00619
33
+ JABBER_DOMAIN=linebank.com.tw
34
+ JABBER_PASSWORD=
35
+ JABBER_KEYCHAIN_SERVICE=jabber-copilot
36
+ JABBER_KEYCHAIN_ACCOUNT=your_jabber_account_number # e.g., BK00619
37
+ JABBER_TO=
38
+ JABBER_NOTIFY_SCRIPT=src/scripts/jabber_notify.py
package/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # @jira-deploy/mcp
2
+
3
+ GitHub Copilot MCP Server,讓 Copilot 能用自然語言操作 Jira 上版流程。
4
+
5
+ ## 功能
6
+
7
+ - 開上版單並自動填欄位(依平台設定)
8
+ - 切換 Jira 狀態(不需要知道 transition ID)
9
+ - 輪詢等待主管 Approve
10
+ - 部署完成後留言記錄
11
+ - 查詢 Jira issue / JQL
12
+ - 查詢 Bitbucket PR、diff、comments,並可回寫 PR comment
13
+ - 查詢 Confluence page、space、version、Team Calendar
14
+ - Slack 通知擴充點(填 env 即啟用)
15
+
16
+ ## 安裝
17
+
18
+ ### 使用已發布版本
19
+
20
+ 若套件已發布到 registry,使用者端可直接安裝:
21
+
22
+ ```bash
23
+ npm install -g @jira-deploy/mcp
24
+ ```
25
+
26
+ 或直接用 `npx`:
27
+
28
+ ```bash
29
+ npx -y @jira-deploy/mcp
30
+ ```
31
+
32
+ MCP app 維持走 npm / npx 發布與安裝,不提供 binary / Homebrew 安裝。
33
+
34
+ ### 本地開發 / 從 monorepo 啟動
35
+
36
+ ```bash
37
+ cd jira-deploy-mcp
38
+ pnpm install
39
+ cd apps/jira-deploy-mcp
40
+ cp .env.example .env
41
+ # 編輯 .env 填入你的 Jira 設定
42
+ ```
43
+
44
+ ## 設定 VSCode MCP
45
+
46
+ 把 `.vscode-mcp.json` 的內容合併到你專案的 `.vscode/mcp.json`,
47
+ 並把 `args` 裡的路徑改成實際位置。
48
+
49
+ 若你是使用已發布版本,可改成類似下面的設定:
50
+
51
+ ```json
52
+ {
53
+ "servers": {
54
+ "jira-deploy": {
55
+ "command": "npx",
56
+ "args": ["-y", "@jira-deploy/mcp"],
57
+ "env": {
58
+ "JIRA_BASE_URL": "https://your-org.atlassian.net",
59
+ "JIRA_API_TOKEN": "your_jira_api_token",
60
+ "BITBUCKET_URL": "https://bitbucket.example.com",
61
+ "BITBUCKET_API_TOKEN": "your_bitbucket_api_token",
62
+ "CONF_BASE_URL": "https://confluence.example.com",
63
+ "CONF_TOKEN": "your_confluence_token"
64
+ }
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ 建議把 Jira 的機密設定放在系統環境變數或 `.env` 檔,
71
+ 不要 commit 到 git(`.env` 已在 .gitignore)。
72
+
73
+ ## 使用者端使用方式
74
+
75
+ 安裝好並接入 MCP client 後,可以直接在 Chat 裡說:
76
+
77
+ ```text
78
+ 幫我開一張 IBK STG 上版單
79
+ 把 CID-1718 切到 Pending Approval
80
+ 等 CID-1718 被 approve
81
+ 把 CID-1718 切到 Approved
82
+ 查 CID-1718 的狀態跟留言
83
+ 查 tw-bank-webapp 這個 PR 的 diff
84
+ 查 Confluence Release Manager 值班表
85
+ ```
86
+
87
+ MCP app 適合:
88
+ - VS Code Copilot Chat
89
+ - 支援 MCP 的桌面 client
90
+ - 想把 Jira 動作當成工具提供給 agent 使用的情境
91
+
92
+ ## Utility Tools
93
+
94
+ 除了上版流程工具,MCP app 也整合了協作者新增的查詢型工具。這些工具現在都在 `apps/jira-deploy-mcp/src/utility-tools.js`,不再需要根目錄額外的 `mcp/` folder 或獨立 server。
95
+
96
+ Jira:
97
+ - `jira_get_issue`
98
+ - `jira_search_issues`
99
+
100
+ Bitbucket:
101
+ - `bitbucket_list_prs`
102
+ - `bitbucket_get_pr`
103
+ - `bitbucket_get_pr_changes`
104
+ - `bitbucket_get_pr_diff`
105
+ - `bitbucket_get_pr_comments`
106
+ - `bitbucket_add_pr_comment`
107
+ - `bitbucket_add_pr_inline_comment`
108
+ - `bitbucket_get_file`
109
+
110
+ Confluence:
111
+ - `confluence_search`
112
+ - `confluence_get_page`
113
+ - `confluence_get_children`
114
+ - `confluence_list_spaces`
115
+ - `confluence_get_page_storage`
116
+ - `confluence_create_page`
117
+ - `confluence_update_page`
118
+ - `confluence_list_versions`
119
+ - `confluence_get_page_version`
120
+ - `confluence_get_calendar_events`
121
+
122
+ ## 使用方式(在 Copilot Chat 說)
123
+
124
+ **完整流程(一句話觸發):**
125
+ ```
126
+ 幫我開 A平台 STG 上版單,版號 v1.2.3,PR 連結 https://github.com/...
127
+ ```
128
+
129
+ **分步控制:**
130
+ ```
131
+ # 1. 開單
132
+ 開一張 A平台 STG 上版單,版號 v1.2.3
133
+
134
+ # 2. 切狀態等待審核
135
+ 把 OPS-123 切到 Pending Approval
136
+
137
+ # 3. 等待 Approve(Copilot 會輪詢)
138
+ 等 OPS-123 被 approve
139
+
140
+ # 4. Approve 後切狀態(觸發 Jira Automation → Jenkins)
141
+ 把 OPS-123 切到 Approved
142
+
143
+ # 5. 等部署完成後收尾
144
+ 在 OPS-123 加留言「STG 部署完成,build: https://jenkins/...」
145
+ 把 OPS-123 切到 Done
146
+ ```
147
+
148
+ ## 平台設定
149
+
150
+ 編輯 `packages/jira-core/platform-config.js` 新增平台:
151
+
152
+ ```js
153
+ export const PLATFORMS = {
154
+ 'a平台': {
155
+ projectKey: 'OPS',
156
+ issueType: 'Task',
157
+ components: ['A Platform'],
158
+ labels: ['deploy', 'stg'],
159
+ customFields: {
160
+ // customfield_10100: { value: 'STG' },
161
+ },
162
+ },
163
+ // 新增更多平台...
164
+ };
165
+ ```
166
+
167
+ ## Jenkins 串接
168
+
169
+ 在 Jira Automation 設定規則:
170
+
171
+ ```
172
+ Trigger: Issue transitioned → to status "Approved"
173
+ Condition: Label includes "stg" AND Component = "A Platform"
174
+ Action: Send web request
175
+ URL: https://jenkins.your-org.com/job/a-platform-stg/buildWithParameters
176
+ Method: POST
177
+ Headers: Authorization: Basic <base64(user:token)>
178
+ Body: {"ENV": "STG", "ISSUE_KEY": "{{issue.key}}"}
179
+ ```
180
+
181
+ 這樣 Agent 切狀態到 Approved,Jenkins build 就自動觸發,不需要 Agent 直接呼叫 Jenkins API。
182
+
183
+ ## Slack 通知擴充
184
+
185
+ 在 `.env` 加入:
186
+
187
+ ```
188
+ SLACK_BOT_TOKEN=xoxb-...
189
+ SLACK_CHANNEL_ID=C0XXXXXXX
190
+ ```
191
+
192
+ 再到 `src/notifier.js` 把 `notifySlack` 的 TODO 取消註解即可,
193
+ 其他程式碼不需要改動。
194
+
195
+ ## 取得 Jira API Token
196
+
197
+ 1. 前往 https://id.atlassian.com/manage-profile/security/api-tokens
198
+ 2. 建立 token,複製到 `.env` 的 `JIRA_API_TOKEN`
199
+
200
+ ## 查詢 Custom Field ID
201
+
202
+ ```bash
203
+ curl -u email:token \
204
+ "https://your-org.atlassian.net/rest/api/2/field" | jq '.[] | select(.custom) | {id, name}'
205
+ ```
206
+
207
+ ## 發布與維護
208
+
209
+ 發布策略與後續維護流程請看:
210
+
211
+ - [docs/publishing.md](../../docs/publishing.md)
212
+ - [docs/maintenance.md](../../docs/maintenance.md)
213
+
214
+ ## Agent Workflow POC (PM → Planner → Coder → Tester)
215
+
216
+ 已新增 4 個自訂 agents:
217
+
218
+ - `.github/agents/pm.agent.md`
219
+ - `.github/agents/planner.agent.md`
220
+ - `.github/agents/coder.agent.md`
221
+ - `.github/agents/tester.agent.md`
222
+
223
+ ### 流程設計
224
+
225
+ 1. **PM** 收需求、定義 scope 與 acceptance criteria。
226
+ 2. **Planner** 產出可執行任務與檔案層級修改清單。
227
+ 3. **Coder** 先讀 codebase 與文件(README、copilot-instructions、package.json)再實作。
228
+ 4. **Tester** 依 acceptance criteria 驗證並給出 release verdict。
229
+
230
+ ### VS Code Copilot Chat 使用
231
+
232
+ - 用 `/agent` 選擇 `PM` 開始。
233
+ - 每個 agent 回覆後可透過 handoff 按鈕切到下一個 agent。
234
+
235
+ ### Copilot CLI 使用
236
+
237
+ CLI 可使用同一批 agent 定義,但沒有 VS Code handoff 按鈕。
238
+ 改用 `/agent` 手動切換:
239
+
240
+ ```text
241
+ /agent PM
242
+ /agent Planner
243
+ /agent Coder
244
+ /agent Tester
245
+ ```
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@jira-deploy/mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP Server for Jira deploy ticket workflow",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ap311036/jira-deploy-mcp.git",
10
+ "directory": "apps/jira-deploy-mcp"
11
+ },
12
+ "bin": {
13
+ "jira-deploy-mcp": "./src/index.js"
14
+ },
15
+ "files": [
16
+ "src/**/*.js",
17
+ "README.md",
18
+ ".env.example"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.0.0",
26
+ "dotenv": "^16.3.0",
27
+ "@jira-deploy/core": "1.0.0"
28
+ },
29
+ "scripts": {
30
+ "start": "node src/index.js",
31
+ "dev": "node --watch src/index.js"
32
+ }
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import {config} from 'dotenv';
3
+ import {fileURLToPath} from 'url';
4
+ import {dirname, join} from 'path';
5
+ import {Server} from '@modelcontextprotocol/sdk/server/index.js';
6
+ import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import {
8
+ CallToolRequestSchema,
9
+ ListToolsRequestSchema,
10
+ } from '@modelcontextprotocol/sdk/types.js';
11
+ import {JiraClient, Notifier, getToolDefinitions, executeTool} from '@jira-deploy/core';
12
+ import {getUtilityToolDefinitions, executeUtilityTool} from './utility-tools.js';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ config({path: join(__dirname, '..', '.env')});
16
+
17
+ const jira = new JiraClient();
18
+ const notifier = new Notifier(jira);
19
+
20
+ const server = new Server(
21
+ {name: '@jira-deploy/mcp', version: '1.0.0'},
22
+ {capabilities: {tools: {}}}
23
+ );
24
+
25
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
26
+ tools: [...getToolDefinitions(), ...getUtilityToolDefinitions()],
27
+ }));
28
+
29
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
30
+ const {name, arguments: args} = request.params;
31
+
32
+ try {
33
+ const utilityResult = await executeUtilityTool(name, args ?? {});
34
+ if (utilityResult) return utilityResult;
35
+ return await executeTool(name, args ?? {}, {jira, notifier});
36
+ } catch (err) {
37
+ return {
38
+ content: [
39
+ {
40
+ type: 'text',
41
+ text: JSON.stringify({error: err.message}),
42
+ },
43
+ ],
44
+ isError: true,
45
+ };
46
+ }
47
+ });
48
+
49
+ const transport = new StdioServerTransport();
50
+ await server.connect(transport);
51
+ console.error('[@jira-deploy/mcp] MCP Server ready');
@@ -0,0 +1,910 @@
1
+ import http from 'http';
2
+ import https from 'https';
3
+
4
+ const DRY_RUN = process.env.DRY_RUN === 'true';
5
+
6
+ function ok(data) {
7
+ return {content: [{type: 'text', text: JSON.stringify(data, null, 2)}]};
8
+ }
9
+
10
+ function text(data) {
11
+ return {content: [{type: 'text', text: data}]};
12
+ }
13
+
14
+ function requiredEnv(names, label) {
15
+ const values = Object.fromEntries(names.map((name) => [name, process.env[name]]));
16
+ const missing = names.filter((name) => !values[name]);
17
+ if (missing.length > 0 && !DRY_RUN) {
18
+ throw new Error(`${label} requires env vars: ${missing.join(', ')}`);
19
+ }
20
+ return values;
21
+ }
22
+
23
+ function parseJsonBody(data) {
24
+ if (!data.trim()) return {};
25
+ return JSON.parse(data);
26
+ }
27
+
28
+ function requestJson(baseUrl, path, {token, method = 'GET', body, timeoutMs = 15000} = {}) {
29
+ return new Promise((resolve, reject) => {
30
+ const url = new URL(path, baseUrl);
31
+ const lib = url.protocol === 'https:' ? https : http;
32
+ const payload = body ? JSON.stringify(body) : null;
33
+ const req = lib.request(
34
+ {
35
+ hostname: url.hostname,
36
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
37
+ path: url.pathname + url.search,
38
+ method,
39
+ rejectUnauthorized: false,
40
+ headers: {
41
+ Accept: 'application/json',
42
+ ...(token ? {Authorization: `Bearer ${token}`} : {}),
43
+ ...(payload
44
+ ? {'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload)}
45
+ : {}),
46
+ },
47
+ },
48
+ (res) => {
49
+ let data = '';
50
+ res.on('data', (chunk) => (data += chunk));
51
+ res.on('end', () => {
52
+ if (res.statusCode >= 200 && res.statusCode < 300) {
53
+ try {
54
+ resolve(parseJsonBody(data));
55
+ } catch (err) {
56
+ reject(new Error(`JSON parse error: ${err.message}`));
57
+ }
58
+ } else {
59
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
60
+ }
61
+ });
62
+ },
63
+ );
64
+ req.on('error', reject);
65
+ req.setTimeout(timeoutMs, () => {
66
+ req.destroy();
67
+ reject(new Error('Request timeout'));
68
+ });
69
+ if (payload) req.write(payload);
70
+ req.end();
71
+ });
72
+ }
73
+
74
+ function requestText(baseUrl, path, {token, timeoutMs = 15000} = {}) {
75
+ return new Promise((resolve, reject) => {
76
+ const url = new URL(path, baseUrl);
77
+ const lib = url.protocol === 'https:' ? https : http;
78
+ const req = lib.request(
79
+ {
80
+ hostname: url.hostname,
81
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
82
+ path: url.pathname + url.search,
83
+ method: 'GET',
84
+ rejectUnauthorized: false,
85
+ headers: {
86
+ Accept: 'text/plain',
87
+ ...(token ? {Authorization: `Bearer ${token}`} : {}),
88
+ },
89
+ },
90
+ (res) => {
91
+ let data = '';
92
+ res.on('data', (chunk) => (data += chunk));
93
+ res.on('end', () => {
94
+ if (res.statusCode >= 200 && res.statusCode < 300) {
95
+ resolve(data);
96
+ } else {
97
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
98
+ }
99
+ });
100
+ },
101
+ );
102
+ req.on('error', reject);
103
+ req.setTimeout(timeoutMs, () => {
104
+ req.destroy();
105
+ reject(new Error('Request timeout'));
106
+ });
107
+ req.end();
108
+ });
109
+ }
110
+
111
+ function jiraConfig() {
112
+ const baseUrl = process.env.JIRA_BASE_URL;
113
+ const token = process.env.JIRA_API_TOKEN ?? process.env.JIRA_TOKEN;
114
+ if (!DRY_RUN && (!baseUrl || !token)) {
115
+ throw new Error('Jira utility tools require JIRA_BASE_URL and JIRA_API_TOKEN');
116
+ }
117
+ return {baseUrl, token};
118
+ }
119
+
120
+ function bitbucketConfig() {
121
+ const baseUrl = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
122
+ const token = process.env.BITBUCKET_API_TOKEN ?? process.env.BITBUCKET_TOKEN;
123
+ if (!DRY_RUN && (!baseUrl || !token)) {
124
+ throw new Error('Bitbucket utility tools require BITBUCKET_URL and BITBUCKET_API_TOKEN');
125
+ }
126
+ return {baseUrl, token};
127
+ }
128
+
129
+ function confluenceConfig() {
130
+ requiredEnv(['CONF_BASE_URL', 'CONF_TOKEN'], 'Confluence utility tools');
131
+ return {baseUrl: process.env.CONF_BASE_URL, token: process.env.CONF_TOKEN};
132
+ }
133
+
134
+ function stripHtml(html) {
135
+ return (html || '')
136
+ .replace(/<[^>]+>/g, ' ')
137
+ .replace(/&nbsp;/g, ' ')
138
+ .replace(/&lt;/g, '<')
139
+ .replace(/&gt;/g, '>')
140
+ .replace(/&amp;/g, '&')
141
+ .replace(/&quot;/g, '"')
142
+ .replace(/\s{2,}/g, ' ')
143
+ .trim();
144
+ }
145
+
146
+ export function getUtilityToolDefinitions() {
147
+ return [
148
+ {
149
+ name: 'jira_get_issue',
150
+ description:
151
+ 'Get a Jira issue by key. Returns summary, status, type, priority, assignee, description, comments, linked issues, and attachments.',
152
+ inputSchema: {
153
+ type: 'object',
154
+ required: ['issueKey'],
155
+ properties: {issueKey: {type: 'string', description: 'Jira issue key, e.g. LBPRJ-10949'}},
156
+ },
157
+ },
158
+ {
159
+ name: 'jira_search_issues',
160
+ description: 'Search Jira issues using JQL. Returns key, summary, status, type, priority, and assignee.',
161
+ inputSchema: {
162
+ type: 'object',
163
+ required: ['jql'],
164
+ properties: {
165
+ jql: {type: 'string', description: 'JQL query string'},
166
+ maxResults: {type: 'number', default: 20, description: 'Max results to return'},
167
+ },
168
+ },
169
+ },
170
+ {
171
+ name: 'bitbucket_list_prs',
172
+ description: 'List pull requests for a Bitbucket repository.',
173
+ inputSchema: {
174
+ type: 'object',
175
+ required: ['projectKey', 'repoSlug'],
176
+ properties: {
177
+ projectKey: {type: 'string'},
178
+ repoSlug: {type: 'string'},
179
+ state: {type: 'string', enum: ['OPEN', 'MERGED', 'DECLINED', 'ALL'], default: 'OPEN'},
180
+ limit: {type: 'number', default: 25},
181
+ },
182
+ },
183
+ },
184
+ {
185
+ name: 'bitbucket_get_pr',
186
+ description: 'Get Bitbucket pull request details and recent activity.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ required: ['projectKey', 'repoSlug', 'prId'],
190
+ properties: {
191
+ projectKey: {type: 'string'},
192
+ repoSlug: {type: 'string'},
193
+ prId: {type: 'number'},
194
+ },
195
+ },
196
+ },
197
+ {
198
+ name: 'bitbucket_get_pr_changes',
199
+ description: 'Get the list of changed files in a Bitbucket pull request.',
200
+ inputSchema: {
201
+ type: 'object',
202
+ required: ['projectKey', 'repoSlug', 'prId'],
203
+ properties: {
204
+ projectKey: {type: 'string'},
205
+ repoSlug: {type: 'string'},
206
+ prId: {type: 'number'},
207
+ },
208
+ },
209
+ },
210
+ {
211
+ name: 'bitbucket_get_pr_diff',
212
+ description: 'Get Bitbucket pull request diff content for code review.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ required: ['projectKey', 'repoSlug', 'prId'],
216
+ properties: {
217
+ projectKey: {type: 'string'},
218
+ repoSlug: {type: 'string'},
219
+ prId: {type: 'number'},
220
+ path: {type: 'string', description: 'Optional file path filter'},
221
+ contextLines: {type: 'number', default: 3},
222
+ excludePatterns: {
223
+ type: 'array',
224
+ items: {type: 'string'},
225
+ default: ['__snapshots__', 'messages_en.json', 'messages_ko.json'],
226
+ },
227
+ },
228
+ },
229
+ },
230
+ {
231
+ name: 'bitbucket_get_pr_comments',
232
+ description: 'Get all comments on a Bitbucket pull request, including inline anchors.',
233
+ inputSchema: {
234
+ type: 'object',
235
+ required: ['projectKey', 'repoSlug', 'prId'],
236
+ properties: {
237
+ projectKey: {type: 'string'},
238
+ repoSlug: {type: 'string'},
239
+ prId: {type: 'number'},
240
+ },
241
+ },
242
+ },
243
+ {
244
+ name: 'bitbucket_add_pr_comment',
245
+ description: 'Add a general comment to a Bitbucket pull request.',
246
+ inputSchema: {
247
+ type: 'object',
248
+ required: ['projectKey', 'repoSlug', 'prId', 'text'],
249
+ properties: {
250
+ projectKey: {type: 'string'},
251
+ repoSlug: {type: 'string'},
252
+ prId: {type: 'number'},
253
+ text: {type: 'string'},
254
+ parentId: {type: 'number'},
255
+ },
256
+ },
257
+ },
258
+ {
259
+ name: 'bitbucket_add_pr_inline_comment',
260
+ description: 'Add an inline Bitbucket pull request comment on a specific file and line.',
261
+ inputSchema: {
262
+ type: 'object',
263
+ required: ['projectKey', 'repoSlug', 'prId', 'text', 'filePath', 'line'],
264
+ properties: {
265
+ projectKey: {type: 'string'},
266
+ repoSlug: {type: 'string'},
267
+ prId: {type: 'number'},
268
+ text: {type: 'string'},
269
+ filePath: {type: 'string'},
270
+ line: {type: 'number'},
271
+ lineType: {type: 'string', enum: ['ADDED', 'REMOVED', 'CONTEXT'], default: 'ADDED'},
272
+ fileType: {type: 'string', enum: ['FROM', 'TO'], default: 'TO'},
273
+ },
274
+ },
275
+ },
276
+ {
277
+ name: 'bitbucket_get_file',
278
+ description: 'Get raw file content from a Bitbucket repository.',
279
+ inputSchema: {
280
+ type: 'object',
281
+ required: ['projectKey', 'repoSlug', 'filePath'],
282
+ properties: {
283
+ projectKey: {type: 'string'},
284
+ repoSlug: {type: 'string'},
285
+ filePath: {type: 'string'},
286
+ branch: {type: 'string', default: 'master'},
287
+ },
288
+ },
289
+ },
290
+ {
291
+ name: 'confluence_search',
292
+ description: 'Search Confluence pages using CQL.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ required: ['query'],
296
+ properties: {
297
+ query: {type: 'string', description: 'CQL query'},
298
+ limit: {type: 'number', default: 10},
299
+ },
300
+ },
301
+ },
302
+ {
303
+ name: 'confluence_get_page',
304
+ description: 'Get full readable content of a Confluence page by ID.',
305
+ inputSchema: {
306
+ type: 'object',
307
+ required: ['pageId'],
308
+ properties: {pageId: {type: 'string'}},
309
+ },
310
+ },
311
+ {
312
+ name: 'confluence_get_children',
313
+ description: 'Get child pages of a Confluence page.',
314
+ inputSchema: {
315
+ type: 'object',
316
+ required: ['pageId'],
317
+ properties: {pageId: {type: 'string'}, limit: {type: 'number', default: 25}},
318
+ },
319
+ },
320
+ {
321
+ name: 'confluence_list_spaces',
322
+ description: 'List available Confluence spaces.',
323
+ inputSchema: {
324
+ type: 'object',
325
+ required: [],
326
+ properties: {limit: {type: 'number', default: 20}},
327
+ },
328
+ },
329
+ {
330
+ name: 'confluence_get_page_storage',
331
+ description: 'Get raw Confluence storage-format HTML for a page.',
332
+ inputSchema: {
333
+ type: 'object',
334
+ required: ['pageId'],
335
+ properties: {pageId: {type: 'string'}},
336
+ },
337
+ },
338
+ {
339
+ name: 'confluence_create_page',
340
+ description: 'Create a new Confluence page under a parent page. Body must be storage-format HTML.',
341
+ inputSchema: {
342
+ type: 'object',
343
+ required: ['spaceKey', 'parentId', 'title', 'body'],
344
+ properties: {
345
+ spaceKey: {type: 'string'},
346
+ parentId: {type: 'string'},
347
+ title: {type: 'string'},
348
+ body: {type: 'string'},
349
+ },
350
+ },
351
+ },
352
+ {
353
+ name: 'confluence_update_page',
354
+ description: 'Update an existing Confluence page title/body and increment the version.',
355
+ inputSchema: {
356
+ type: 'object',
357
+ required: ['pageId', 'body'],
358
+ properties: {
359
+ pageId: {type: 'string'},
360
+ title: {type: 'string'},
361
+ body: {type: 'string'},
362
+ minorEdit: {type: 'boolean', default: false},
363
+ },
364
+ },
365
+ },
366
+ {
367
+ name: 'confluence_list_versions',
368
+ description: 'List historical versions of a Confluence page.',
369
+ inputSchema: {
370
+ type: 'object',
371
+ required: ['pageId'],
372
+ properties: {
373
+ pageId: {type: 'string'},
374
+ limit: {type: 'number', default: 25},
375
+ start: {type: 'number', default: 0},
376
+ },
377
+ },
378
+ },
379
+ {
380
+ name: 'confluence_get_page_version',
381
+ description: 'Get readable body and metadata of a historical Confluence page version.',
382
+ inputSchema: {
383
+ type: 'object',
384
+ required: ['pageId', 'version'],
385
+ properties: {pageId: {type: 'string'}, version: {type: 'number'}},
386
+ },
387
+ },
388
+ {
389
+ name: 'confluence_get_calendar_events',
390
+ description:
391
+ 'Query Confluence Team Calendar events. Useful for finding Release Manager / Sign off staff.',
392
+ inputSchema: {
393
+ type: 'object',
394
+ required: ['subCalendarId', 'date'],
395
+ properties: {
396
+ subCalendarId: {type: 'string'},
397
+ date: {type: 'string', description: 'YYYY-MM-DD'},
398
+ },
399
+ },
400
+ },
401
+ ];
402
+ }
403
+
404
+ export async function executeUtilityTool(name, args = {}) {
405
+ switch (name) {
406
+ case 'jira_get_issue':
407
+ return jiraGetIssue(args);
408
+ case 'jira_search_issues':
409
+ return jiraSearchIssues(args);
410
+ case 'bitbucket_list_prs':
411
+ return bitbucketListPrs(args);
412
+ case 'bitbucket_get_pr':
413
+ return bitbucketGetPr(args);
414
+ case 'bitbucket_get_pr_changes':
415
+ return bitbucketGetPrChanges(args);
416
+ case 'bitbucket_get_pr_diff':
417
+ return bitbucketGetPrDiff(args);
418
+ case 'bitbucket_get_pr_comments':
419
+ return bitbucketGetPrComments(args);
420
+ case 'bitbucket_add_pr_comment':
421
+ return bitbucketAddPrComment(args);
422
+ case 'bitbucket_add_pr_inline_comment':
423
+ return bitbucketAddPrInlineComment(args);
424
+ case 'bitbucket_get_file':
425
+ return bitbucketGetFile(args);
426
+ case 'confluence_search':
427
+ return confluenceSearch(args);
428
+ case 'confluence_get_page':
429
+ return confluenceGetPage(args);
430
+ case 'confluence_get_children':
431
+ return confluenceGetChildren(args);
432
+ case 'confluence_list_spaces':
433
+ return confluenceListSpaces(args);
434
+ case 'confluence_get_page_storage':
435
+ return confluenceGetPageStorage(args);
436
+ case 'confluence_create_page':
437
+ return confluenceCreatePage(args);
438
+ case 'confluence_update_page':
439
+ return confluenceUpdatePage(args);
440
+ case 'confluence_list_versions':
441
+ return confluenceListVersions(args);
442
+ case 'confluence_get_page_version':
443
+ return confluenceGetPageVersion(args);
444
+ case 'confluence_get_calendar_events':
445
+ return confluenceGetCalendarEvents(args);
446
+ default:
447
+ return null;
448
+ }
449
+ }
450
+
451
+ async function jiraGetIssue({issueKey}) {
452
+ if (DRY_RUN) {
453
+ return ok({key: issueKey, fields: {summary: '[DRY] Mock issue', status: {name: 'To Do'}}});
454
+ }
455
+ const {baseUrl, token} = jiraConfig();
456
+ const data = await requestJson(
457
+ baseUrl,
458
+ `/rest/api/2/issue/${issueKey}?expand=renderedFields`,
459
+ {token},
460
+ );
461
+ const f = data.fields || {};
462
+ return ok({
463
+ key: data.key,
464
+ summary: f.summary || '',
465
+ type: f.issuetype?.name || '',
466
+ status: f.status?.name || '',
467
+ priority: f.priority?.name || '',
468
+ assignee: f.assignee?.displayName || '',
469
+ reporter: f.reporter?.displayName || '',
470
+ created: (f.created || '').slice(0, 10),
471
+ updated: (f.updated || '').slice(0, 10),
472
+ labels: f.labels || [],
473
+ description: f.description || '',
474
+ comments: (f.comment?.comments || []).map((c) => ({
475
+ author: c.author?.displayName || '',
476
+ created: (c.created || '').slice(0, 10),
477
+ body: c.body || '',
478
+ })),
479
+ linkedIssues: (f.issuelinks || []).map((l) => {
480
+ const issue = l.inwardIssue || l.outwardIssue || {};
481
+ const rel = l.inwardIssue ? l.type?.inward : l.type?.outward;
482
+ return `${rel} -> ${issue.key} [${issue.fields?.status?.name || ''}] ${issue.fields?.summary || ''}`;
483
+ }),
484
+ attachments: (f.attachment || []).map((a) => a.filename),
485
+ });
486
+ }
487
+
488
+ async function jiraSearchIssues({jql, maxResults = 20}) {
489
+ if (DRY_RUN) return ok({total: 0, issues: []});
490
+ const {baseUrl, token} = jiraConfig();
491
+ const data = await requestJson(
492
+ baseUrl,
493
+ `/rest/api/2/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=summary,status,assignee,issuetype,priority`,
494
+ {token},
495
+ );
496
+ const issues = (data.issues || []).map((i) => ({
497
+ key: i.key,
498
+ summary: i.fields?.summary || '',
499
+ type: i.fields?.issuetype?.name || '',
500
+ status: i.fields?.status?.name || '',
501
+ priority: i.fields?.priority?.name || '',
502
+ assignee: i.fields?.assignee?.displayName || '',
503
+ }));
504
+ return ok({total: data.total, issues});
505
+ }
506
+
507
+ async function bitbucketListPrs({projectKey, repoSlug, state = 'OPEN', limit = 25}) {
508
+ if (DRY_RUN) return ok({total: 0, prs: []});
509
+ const {baseUrl, token} = bitbucketConfig();
510
+ const data = await requestJson(
511
+ baseUrl,
512
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests?state=${state}&limit=${limit}`,
513
+ {token},
514
+ );
515
+ const prs = (data.values || []).map((pr) => ({
516
+ id: pr.id,
517
+ title: pr.title,
518
+ state: pr.state,
519
+ author: pr.author?.user?.displayName || '',
520
+ sourceBranch: pr.fromRef?.displayId || '',
521
+ targetBranch: pr.toRef?.displayId || '',
522
+ updated: pr.updatedDate ? new Date(pr.updatedDate).toISOString().slice(0, 10) : '',
523
+ }));
524
+ return ok({total: data.size, prs});
525
+ }
526
+
527
+ async function bitbucketGetPr({projectKey, repoSlug, prId}) {
528
+ if (DRY_RUN) return ok({id: prId, title: '[DRY] Mock PR', state: 'OPEN'});
529
+ const {baseUrl, token} = bitbucketConfig();
530
+ const [pr, activities] = await Promise.all([
531
+ requestJson(baseUrl, `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}`, {token}),
532
+ requestJson(
533
+ baseUrl,
534
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/activities?limit=20`,
535
+ {token},
536
+ ),
537
+ ]);
538
+ return ok({
539
+ id: pr.id,
540
+ title: pr.title,
541
+ state: pr.state,
542
+ author: pr.author?.user?.displayName || '',
543
+ sourceBranch: pr.fromRef?.displayId || '',
544
+ targetBranch: pr.toRef?.displayId || '',
545
+ created: pr.createdDate ? new Date(pr.createdDate).toISOString().slice(0, 10) : '',
546
+ updated: pr.updatedDate ? new Date(pr.updatedDate).toISOString().slice(0, 10) : '',
547
+ description: pr.description || '',
548
+ reviewers: (pr.reviewers || []).map((r) => ({
549
+ name: r.user?.displayName || '',
550
+ status: r.status || '',
551
+ })),
552
+ recentActivity: (activities.values || []).slice(0, 10).map((a) => ({
553
+ action: a.action,
554
+ user: a.user?.displayName || '',
555
+ comment: a.comment?.text?.slice(0, 200) || '',
556
+ })),
557
+ });
558
+ }
559
+
560
+ async function bitbucketGetPrChanges({projectKey, repoSlug, prId}) {
561
+ if (DRY_RUN) return ok({total: 0, files: []});
562
+ const {baseUrl, token} = bitbucketConfig();
563
+ const data = await requestJson(
564
+ baseUrl,
565
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/changes?limit=100`,
566
+ {token},
567
+ );
568
+ const files = (data.values || []).map((c) => ({
569
+ path: c.path?.toString || '',
570
+ srcPath: c.srcPath?.toString || '',
571
+ type: c.type,
572
+ }));
573
+ return ok({total: files.length, files});
574
+ }
575
+
576
+ async function bitbucketGetPrDiff({
577
+ projectKey,
578
+ repoSlug,
579
+ prId,
580
+ path,
581
+ contextLines = 3,
582
+ excludePatterns = ['__snapshots__', 'messages_en.json', 'messages_ko.json'],
583
+ }) {
584
+ if (DRY_RUN) return ok({diffs: []});
585
+ const {baseUrl, token} = bitbucketConfig();
586
+ const pathParam = path ? `&path=${encodeURIComponent(path)}` : '';
587
+ const data = await requestJson(
588
+ baseUrl,
589
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/diff?contextLines=${contextLines}&whitespace=IGNORE_ALL${pathParam}`,
590
+ {token},
591
+ );
592
+ const diffs = (data.diffs || [])
593
+ .filter((d) => {
594
+ const filePath = d.destination?.toString || d.source?.toString || '';
595
+ return !excludePatterns.some((pattern) => filePath.includes(pattern));
596
+ })
597
+ .map((d) => {
598
+ const filePath = d.destination?.toString || d.source?.toString || '';
599
+ const hunks = (d.hunks || []).map((h) => {
600
+ const lines = [];
601
+ for (const seg of h.segments || []) {
602
+ const prefix = seg.type === 'ADDED' ? '+' : seg.type === 'REMOVED' ? '-' : ' ';
603
+ for (const l of seg.lines || []) {
604
+ lines.push({
605
+ prefix,
606
+ lineType: seg.type,
607
+ srcLine: l.source || null,
608
+ dstLine: l.destination || null,
609
+ content: l.line || '',
610
+ });
611
+ }
612
+ }
613
+ return {
614
+ srcLine: h.sourceLine,
615
+ srcSpan: h.sourceSpan,
616
+ dstLine: h.destinationLine,
617
+ dstSpan: h.destinationSpan,
618
+ lines,
619
+ };
620
+ });
621
+ return {filePath, srcPath: d.source?.toString || '', hunks};
622
+ });
623
+ return ok({diffs});
624
+ }
625
+
626
+ async function bitbucketGetPrComments({projectKey, repoSlug, prId}) {
627
+ if (DRY_RUN) return ok({total: 0, comments: []});
628
+ const {baseUrl, token} = bitbucketConfig();
629
+ const data = await requestJson(
630
+ baseUrl,
631
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/activities?limit=100`,
632
+ {token},
633
+ );
634
+ const comments = (data.values || [])
635
+ .filter((a) => a.action === 'COMMENTED')
636
+ .map((a) => ({
637
+ id: a.comment?.id,
638
+ author: a.user?.displayName || '',
639
+ text: a.comment?.text || '',
640
+ created: a.createdDate ? new Date(a.createdDate).toISOString().slice(0, 10) : '',
641
+ anchor: a.commentAnchor
642
+ ? {
643
+ path: a.commentAnchor.path || '',
644
+ line: a.commentAnchor.line,
645
+ lineType: a.commentAnchor.lineType,
646
+ fileType: a.commentAnchor.fileType,
647
+ }
648
+ : null,
649
+ }));
650
+ return ok({total: comments.length, comments});
651
+ }
652
+
653
+ async function bitbucketAddPrComment({projectKey, repoSlug, prId, text: commentText, parentId}) {
654
+ const {baseUrl, token} = bitbucketConfig();
655
+ if (DRY_RUN) return ok({success: true, dryRun: true, text: commentText});
656
+ const result = await requestJson(
657
+ baseUrl,
658
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/comments`,
659
+ {method: 'POST', token, body: {text: commentText, ...(parentId ? {parent: {id: parentId}} : {})}},
660
+ );
661
+ return ok({success: true, commentId: result.id, text: result.text});
662
+ }
663
+
664
+ async function bitbucketAddPrInlineComment({
665
+ projectKey,
666
+ repoSlug,
667
+ prId,
668
+ text: commentText,
669
+ filePath,
670
+ line,
671
+ lineType = 'ADDED',
672
+ fileType = 'TO',
673
+ }) {
674
+ const {baseUrl, token} = bitbucketConfig();
675
+ if (DRY_RUN) return ok({success: true, dryRun: true, text: commentText, filePath, line});
676
+ const result = await requestJson(
677
+ baseUrl,
678
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/comments`,
679
+ {
680
+ method: 'POST',
681
+ token,
682
+ body: {
683
+ text: commentText,
684
+ anchor: {line, lineType, fileType, path: filePath, srcPath: filePath},
685
+ },
686
+ },
687
+ );
688
+ return ok({success: true, commentId: result.id, text: result.text, anchor: result.anchor});
689
+ }
690
+
691
+ async function bitbucketGetFile({projectKey, repoSlug, filePath, branch = 'master'}) {
692
+ if (DRY_RUN) return text('[DRY] Mock file content');
693
+ const {baseUrl, token} = bitbucketConfig();
694
+ const encodedPath = filePath.split('/').map(encodeURIComponent).join('/');
695
+ const content = await requestText(
696
+ baseUrl,
697
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/raw/${encodedPath}?at=${encodeURIComponent(branch)}`,
698
+ {token},
699
+ );
700
+ return text(content);
701
+ }
702
+
703
+ async function confluenceSearch({query, limit = 10}) {
704
+ if (DRY_RUN) return ok({total: 0, results: []});
705
+ const {baseUrl, token} = confluenceConfig();
706
+ const data = await requestJson(
707
+ baseUrl,
708
+ `/rest/api/content/search?cql=${encodeURIComponent(query)}&limit=${limit}&expand=space,version`,
709
+ {token},
710
+ );
711
+ const results = (data.results || []).map((r) => ({
712
+ id: r.id,
713
+ title: r.title,
714
+ type: r.type,
715
+ space: r.space?.name || '',
716
+ spaceKey: r.space?.key || '',
717
+ url: `${baseUrl}/pages/viewpage.action?pageId=${r.id}`,
718
+ lastModified: (r.version?.when || '').slice(0, 10),
719
+ lastModifiedBy: r.version?.by?.displayName || '',
720
+ }));
721
+ return ok({total: data.totalSize, results});
722
+ }
723
+
724
+ async function confluenceGetPage({pageId}) {
725
+ if (DRY_RUN) return ok({id: pageId, title: '[DRY] Mock page', body: 'dry'});
726
+ const {baseUrl, token} = confluenceConfig();
727
+ const data = await requestJson(
728
+ baseUrl,
729
+ `/rest/api/content/${pageId}?expand=body.storage,version,space,ancestors`,
730
+ {token},
731
+ );
732
+ return ok({
733
+ id: data.id,
734
+ title: data.title,
735
+ type: data.type,
736
+ space: data.space?.name || '',
737
+ spaceKey: data.space?.key || '',
738
+ url: `${baseUrl}/pages/viewpage.action?pageId=${data.id}`,
739
+ version: data.version?.number,
740
+ lastModified: (data.version?.when || '').slice(0, 10),
741
+ lastModifiedBy: data.version?.by?.displayName || '',
742
+ ancestors: (data.ancestors || []).map((a) => ({id: a.id, title: a.title})),
743
+ body: stripHtml(data.body?.storage?.value || ''),
744
+ });
745
+ }
746
+
747
+ async function confluenceGetChildren({pageId, limit = 25}) {
748
+ if (DRY_RUN) return ok({total: 0, children: []});
749
+ const {baseUrl, token} = confluenceConfig();
750
+ const data = await requestJson(
751
+ baseUrl,
752
+ `/rest/api/content/${pageId}/child/page?limit=${limit}&expand=version,space`,
753
+ {token},
754
+ );
755
+ const children = (data.results || []).map((r) => ({
756
+ id: r.id,
757
+ title: r.title,
758
+ url: `${baseUrl}/pages/viewpage.action?pageId=${r.id}`,
759
+ lastModified: (r.version?.when || '').slice(0, 10),
760
+ }));
761
+ return ok({total: data.size, children});
762
+ }
763
+
764
+ async function confluenceListSpaces({limit = 20}) {
765
+ if (DRY_RUN) return ok({total: 0, spaces: []});
766
+ const {baseUrl, token} = confluenceConfig();
767
+ const data = await requestJson(baseUrl, `/rest/api/space?limit=${limit}&expand=description.plain`, {
768
+ token,
769
+ });
770
+ const spaces = (data.results || []).map((s) => ({
771
+ key: s.key,
772
+ name: s.name,
773
+ type: s.type,
774
+ description: s.description?.plain?.value?.slice(0, 100) || '',
775
+ }));
776
+ return ok({total: data.size, spaces});
777
+ }
778
+
779
+ async function confluenceGetPageStorage({pageId}) {
780
+ if (DRY_RUN) return ok({id: pageId, title: '[DRY] Mock page', version: 1, body: '<p>dry</p>'});
781
+ const {baseUrl, token} = confluenceConfig();
782
+ const data = await requestJson(baseUrl, `/rest/api/content/${pageId}?expand=body.storage,version,title`, {
783
+ token,
784
+ });
785
+ return ok({
786
+ id: data.id,
787
+ title: data.title,
788
+ version: data.version?.number,
789
+ body: data.body?.storage?.value || '',
790
+ });
791
+ }
792
+
793
+ async function confluenceCreatePage({spaceKey, parentId, title, body}) {
794
+ const {baseUrl, token} = confluenceConfig();
795
+ if (DRY_RUN) return ok({id: 'DRY', title, url: `${baseUrl}/pages/viewpage.action?pageId=DRY`});
796
+ const data = await requestJson(baseUrl, '/rest/api/content', {
797
+ method: 'POST',
798
+ token,
799
+ body: {
800
+ type: 'page',
801
+ title,
802
+ ancestors: [{id: parentId}],
803
+ space: {key: spaceKey},
804
+ body: {storage: {value: body, representation: 'storage'}},
805
+ },
806
+ timeoutMs: 20000,
807
+ });
808
+ return ok({id: data.id, title: data.title, url: `${baseUrl}/pages/viewpage.action?pageId=${data.id}`});
809
+ }
810
+
811
+ async function confluenceUpdatePage({pageId, title, body, minorEdit = false}) {
812
+ const {baseUrl, token} = confluenceConfig();
813
+ if (DRY_RUN) return ok({id: pageId, title: title || '[DRY] Mock page', version: 2, dryRun: true});
814
+ const current = await requestJson(baseUrl, `/rest/api/content/${pageId}?expand=version,title`, {token});
815
+ const nextVersion = (current.version?.number || 0) + 1;
816
+ const resolvedTitle = title || current.title;
817
+ const data = await requestJson(baseUrl, `/rest/api/content/${pageId}`, {
818
+ method: 'PUT',
819
+ token,
820
+ body: {
821
+ type: 'page',
822
+ title: resolvedTitle,
823
+ version: {number: nextVersion, minorEdit: !!minorEdit},
824
+ body: {storage: {value: body, representation: 'storage'}},
825
+ },
826
+ timeoutMs: 20000,
827
+ });
828
+ return ok({
829
+ id: data.id,
830
+ title: data.title,
831
+ version: data.version?.number,
832
+ url: `${baseUrl}/pages/viewpage.action?pageId=${data.id}`,
833
+ });
834
+ }
835
+
836
+ async function confluenceListVersions({pageId, limit = 25, start = 0}) {
837
+ if (DRY_RUN) return ok({total: 0, start, limit, versions: []});
838
+ const {baseUrl, token} = confluenceConfig();
839
+ const data = await requestJson(
840
+ baseUrl,
841
+ `/rest/api/content/${pageId}/version?limit=${limit}&start=${start}`,
842
+ {token},
843
+ );
844
+ const versions = (data.results || []).map((v) => ({
845
+ version: v.number,
846
+ date: (v.when || '').slice(0, 10),
847
+ author: v.by?.displayName || '',
848
+ message: v.message || '',
849
+ minorEdit: !!v.minorEdit,
850
+ }));
851
+ return ok({total: data.size, start: data.start, limit: data.limit, versions});
852
+ }
853
+
854
+ async function confluenceGetPageVersion({pageId, version}) {
855
+ if (DRY_RUN) return ok({id: pageId, title: '[DRY] Mock page', version, body: 'dry'});
856
+ const {baseUrl, token} = confluenceConfig();
857
+ const data = await requestJson(
858
+ baseUrl,
859
+ `/rest/api/content/${pageId}?status=historical&version=${version}&expand=body.storage,version`,
860
+ {token},
861
+ );
862
+ return ok({
863
+ id: data.id,
864
+ title: data.title,
865
+ version: data.version?.number,
866
+ date: (data.version?.when || '').slice(0, 10),
867
+ author: data.version?.by?.displayName || '',
868
+ message: data.version?.message || '',
869
+ body: stripHtml(data.body?.storage?.value || ''),
870
+ });
871
+ }
872
+
873
+ async function confluenceGetCalendarEvents({subCalendarId, date}) {
874
+ if (DRY_RUN) {
875
+ return ok({
876
+ date,
877
+ total: 1,
878
+ events: [{what: 'Sign off staff', who: 'Alvin Wang (BK00236)'}],
879
+ signOffStaff: {name: 'Alvin Wang (BK00236)'},
880
+ dryRun: true,
881
+ });
882
+ }
883
+ const {baseUrl, token} = confluenceConfig();
884
+ const nextDay = new Date(date);
885
+ nextDay.setDate(nextDay.getDate() + 1);
886
+ const endDate = nextDay.toISOString().slice(0, 10);
887
+ const data = await requestJson(
888
+ baseUrl,
889
+ `/rest/calendar-services/1.0/calendar/events.json?subCalendarId=${encodeURIComponent(subCalendarId)}&start=${date}&end=${endDate}&userTimeZoneId=Asia%2FTaipei`,
890
+ {token},
891
+ );
892
+ const events = (data.events ?? []).map((e) => ({
893
+ what: e.what ?? e.title ?? '',
894
+ who: e.who ?? e.displayName ?? '',
895
+ start: e.start ?? '',
896
+ end: e.end ?? '',
897
+ allDay: !!e.allDay,
898
+ }));
899
+ const signOff = events.find(
900
+ (e) =>
901
+ (e.what ?? '').toLowerCase().includes('sign off staff') ||
902
+ (e.who ?? '').toLowerCase().includes('sign off staff'),
903
+ );
904
+ return ok({
905
+ date,
906
+ total: events.length,
907
+ events,
908
+ signOffStaff: signOff ? {name: signOff.who || signOff.what, event: signOff} : null,
909
+ });
910
+ }