@hanhnd/agent-kit 1.0.42 → 1.0.44

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/README.md CHANGED
@@ -27,6 +27,29 @@ Claude Code and Codex plugin manifests already use this package:
27
27
  }
28
28
  ```
29
29
 
30
+ ## API Token Scopes
31
+
32
+ Integration tools call the Atlassian REST APIs with scoped API tokens. Create each token at [id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens) ("Create API token with scopes") and grant the scopes below.
33
+
34
+ | Token | Scopes |
35
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
36
+ | `BITBUCKET_API_TOKEN` | `read:pullrequest:bitbucket`, `write:pullrequest:bitbucket`, `read:repository:bitbucket`, `read:workspace:bitbucket`, `read:user:bitbucket` |
37
+ | `JIRA_API_TOKEN` | `read:jira-work` |
38
+ | `CONFLUENCE_API_TOKEN` | `read:page:confluence` |
39
+
40
+ Per tool:
41
+
42
+ | Tool | Scopes |
43
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
44
+ | `kit_get_bitbucket_pr` | `read:pullrequest:bitbucket`; `read:repository:bitbucket` for the diff (the PR diff redirects to the repository diff) |
45
+ | `kit_get_bitbucket_pr_comments` | `read:pullrequest:bitbucket` |
46
+ | `kit_create_bitbucket_pr` | `read:pullrequest:bitbucket`, `write:pullrequest:bitbucket`; `read:workspace:bitbucket` for reviewers given by name; `read:user:bitbucket` to skip the PR author |
47
+ | `kit_find_bitbucket_reviewers` | `read:workspace:bitbucket`; `read:user:bitbucket` to flag the PR author |
48
+ | `kit_jira_get_ticket` | `read:jira-work` |
49
+ | `kit_confluence_get_page` | `read:page:confluence` |
50
+
51
+ A missing scope surfaces as a 401/403; the error message lists the scopes the tool needs.
52
+
30
53
  ## Development
31
54
 
32
55
  From the repository root:
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,335 @@
1
+ import * as assert from 'node:assert/strict';
2
+ import { afterEach, describe, mock, test } from 'node:test';
3
+ import { MAX_PAGES, handleCreateBitbucketPr, handleFindBitbucketReviewers, handleGetBitbucketPrComments, matchReviewers, normalizeName, resolveBitbucketPr, resolveBitbucketRepo, } from './integration.js';
4
+ const API = 'https://api.bitbucket.org/2.0';
5
+ const PR_URL = 'https://bitbucket.org/acme/web-app/pull-requests/42';
6
+ process.env.BITBUCKET_USER_EMAIL = 'dev@acme.test';
7
+ process.env.BITBUCKET_API_TOKEN = 'bb-token';
8
+ process.env.BITBUCKET_DEFAULT_WORKSPACE = 'acme';
9
+ /** Replace the global fetch for one test with a queue of responses; `afterEach` restores it. */
10
+ function stubFetch(responses) {
11
+ const calls = [];
12
+ mock.method(globalThis, 'fetch', async (input, init) => {
13
+ calls.push({
14
+ url: String(input),
15
+ method: init?.method ?? 'GET',
16
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
17
+ });
18
+ const next = responses[Math.min(calls.length - 1, responses.length - 1)];
19
+ return new Response(typeof next.body === 'string' ? next.body : JSON.stringify(next.body), {
20
+ status: next.status,
21
+ headers: { 'content-type': 'application/json' },
22
+ });
23
+ });
24
+ return calls;
25
+ }
26
+ afterEach(() => mock.restoreAll());
27
+ describe('resolveBitbucketPr / resolveBitbucketRepo', () => {
28
+ test('B1: PR URL resolves to workspace, repo and id', () => {
29
+ assert.deepEqual(resolveBitbucketPr(PR_URL), { ok: true, ws: 'acme', repo: 'web-app', prId: 42 });
30
+ });
31
+ test('B2: numeric PR id uses the default workspace', () => {
32
+ assert.deepEqual(resolveBitbucketPr('7', undefined, 'api'), { ok: true, ws: 'acme', repo: 'api', prId: 7 });
33
+ });
34
+ test('B3: numeric PR id without repoSlug is rejected', () => {
35
+ assert.equal(resolveBitbucketPr('7').ok, false);
36
+ });
37
+ test('B4: repo accepts URL, workspace/repo pair, clone URL and bare slug', () => {
38
+ const expected = { ok: true, ws: 'acme', repo: 'web-app' };
39
+ assert.deepEqual(resolveBitbucketRepo('https://bitbucket.org/acme/web-app/src/main/'), expected);
40
+ assert.deepEqual(resolveBitbucketRepo('acme/web-app'), expected);
41
+ assert.deepEqual(resolveBitbucketRepo('git@bitbucket.org:acme/web-app.git'), expected);
42
+ assert.deepEqual(resolveBitbucketRepo('web-app'), expected);
43
+ assert.deepEqual(resolveBitbucketRepo('web-app', 'other'), { ok: true, ws: 'other', repo: 'web-app' });
44
+ });
45
+ test('B5: slugs with URL-breaking characters are rejected', () => {
46
+ assert.equal(resolveBitbucketRepo('acme/web app').ok, false);
47
+ assert.equal(resolveBitbucketRepo('../evil').ok, false);
48
+ });
49
+ });
50
+ const SELF_UUID = '{11111111-1111-1111-1111-111111111111}';
51
+ const MEMBERS = [
52
+ { uuid: '{aaaaaaaa-0000-0000-0000-000000000001}', display_name: 'Trần Văn Điền', nickname: 'dientv' },
53
+ { uuid: '{aaaaaaaa-0000-0000-0000-000000000002}', display_name: 'Nguyễn Tiến Dũng', nickname: 'dungnt' },
54
+ { uuid: '{aaaaaaaa-0000-0000-0000-000000000003}', display_name: 'Lê Dũng', nickname: 'dungle' },
55
+ { uuid: SELF_UUID, display_name: 'Ngô Hạnh', nickname: 'hanhnd' },
56
+ ];
57
+ const member = (i) => ({
58
+ uuid: MEMBERS[i].uuid,
59
+ displayName: MEMBERS[i].display_name,
60
+ nickname: MEMBERS[i].nickname,
61
+ });
62
+ /** Route requests by URL (lookups run concurrently, so order is not stable); unrouted → 500. */
63
+ function stubRoutes(route) {
64
+ const calls = [];
65
+ mock.method(globalThis, 'fetch', async (input, init) => {
66
+ const url = String(input);
67
+ const method = init?.method ?? 'GET';
68
+ calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined });
69
+ const res = route(url, method) ?? { status: 500, body: `unrouted ${method} ${url}` };
70
+ return new Response(typeof res.body === 'string' ? res.body : JSON.stringify(res.body), {
71
+ status: res.status,
72
+ headers: { 'content-type': 'application/json' },
73
+ });
74
+ });
75
+ return calls;
76
+ }
77
+ /** Workspace members split over two pages, the current user, and a 201 for PR creation. */
78
+ function bitbucketRoutes(overrides = () => undefined) {
79
+ return (url, method) => {
80
+ const hit = overrides(url, method);
81
+ if (hit)
82
+ return hit;
83
+ if (url === `${API}/workspaces/acme/members?pagelen=100`) {
84
+ return {
85
+ status: 200,
86
+ body: { values: MEMBERS.slice(0, 2).map((user) => ({ user })), next: `${API}/workspaces/acme/members?page=2` },
87
+ };
88
+ }
89
+ if (url === `${API}/workspaces/acme/members?page=2`) {
90
+ return { status: 200, body: { values: MEMBERS.slice(2).map((user) => ({ user })) } };
91
+ }
92
+ if (url === `${API}/user`)
93
+ return { status: 200, body: { uuid: SELF_UUID } };
94
+ if (method === 'POST')
95
+ return { status: 201, body: created };
96
+ return undefined;
97
+ };
98
+ }
99
+ const created = {
100
+ id: 101,
101
+ title: 'Add login',
102
+ state: 'OPEN',
103
+ draft: true,
104
+ source: { branch: { name: 'feature/login' } },
105
+ destination: { branch: { name: 'develop' } },
106
+ reviewers: [{ display_name: 'Bob' }],
107
+ links: { html: { href: 'https://bitbucket.org/acme/web-app/pull-requests/101' } },
108
+ };
109
+ const postOf = (calls) => calls.find((c) => c.method === 'POST');
110
+ describe('matchReviewers / normalizeName', () => {
111
+ const members = MEMBERS.map((_, i) => member(i));
112
+ test('N1: normalizeName strips Vietnamese diacritics including đ', () => {
113
+ assert.equal(normalizeName(' Trần Văn Điền '), 'tran van dien');
114
+ });
115
+ test('N2: a single word matches a whole word of the display name, accent-insensitive', () => {
116
+ const r = matchReviewers(['Dien'], members);
117
+ assert.deepEqual(r.resolved, [{ query: 'Dien', member: member(0) }]);
118
+ });
119
+ test('N3: a word shared by several members is ambiguous, never guessed', () => {
120
+ const r = matchReviewers(['Dung'], members);
121
+ assert.equal(r.resolved.length, 0);
122
+ assert.deepEqual(r.ambiguous[0].candidates.map((c) => c.uuid), [member(1).uuid, member(2).uuid]);
123
+ });
124
+ test('N4: full name, nickname and multi-word queries disambiguate', () => {
125
+ const r = matchReviewers(['nguyen tien dung', 'dungle', 'Le Dung'], members);
126
+ assert.deepEqual(r.resolved.map((x) => x.member.uuid), [member(1).uuid, member(2).uuid, member(2).uuid]);
127
+ });
128
+ test('N5: partial words do not match', () => {
129
+ assert.deepEqual(matchReviewers(['Die'], members).unmatched, ['Die']);
130
+ });
131
+ });
132
+ describe('handleCreateBitbucketPr', () => {
133
+ test('C1: POSTs the documented body and renders id + URL', async () => {
134
+ const calls = stubRoutes(bitbucketRoutes());
135
+ const text = (await handleCreateBitbucketPr({
136
+ repository: 'acme/web-app',
137
+ title: ' Add login ',
138
+ sourceBranch: 'feature/login',
139
+ destinationBranch: 'develop',
140
+ description: 'Body',
141
+ closeSourceBranch: true,
142
+ draft: true,
143
+ reviewers: ['504c3b62-8120-4f0c-a7bc-87800b9d6f70'],
144
+ })).content[0].text;
145
+ const post = postOf(calls);
146
+ assert.ok(post);
147
+ assert.equal(post.url, `${API}/repositories/acme/web-app/pullrequests`);
148
+ assert.deepEqual(post.body, {
149
+ title: 'Add login',
150
+ source: { branch: { name: 'feature/login' } },
151
+ destination: { branch: { name: 'develop' } },
152
+ description: 'Body',
153
+ close_source_branch: true,
154
+ draft: true,
155
+ reviewers: [{ uuid: '{504c3b62-8120-4f0c-a7bc-87800b9d6f70}' }],
156
+ });
157
+ assert.ok(!calls.some((c) => c.url.includes('/members')), 'UUID-only reviewers need no member lookup');
158
+ assert.match(text, /Created PR #101: Add login/);
159
+ assert.match(text, /pull-requests\/101/);
160
+ assert.match(text, /feature\/login → develop/);
161
+ assert.match(text, /\(draft\)/);
162
+ assert.match(text, /Reviewers:\*\* Bob/);
163
+ });
164
+ test('C2: omits destination so Bitbucket defaults to the main branch; no lookups without reviewers', async () => {
165
+ const calls = stubRoutes(bitbucketRoutes());
166
+ await handleCreateBitbucketPr({ repository: 'web-app', title: 'T', sourceBranch: 'feat' });
167
+ assert.equal(calls.length, 1);
168
+ assert.deepEqual(postOf(calls)?.body, { title: 'T', source: { branch: { name: 'feat' } } });
169
+ });
170
+ test('C3: names resolve across member pages, dedupe, and drop the PR author', async () => {
171
+ const calls = stubRoutes(bitbucketRoutes());
172
+ const text = (await handleCreateBitbucketPr({
173
+ repository: 'acme/web-app',
174
+ title: 'T',
175
+ sourceBranch: 'f',
176
+ reviewers: ['Dien', 'dientv', 'Le Dung', 'Hanh'],
177
+ })).content[0].text;
178
+ assert.deepEqual(postOf(calls)?.body, {
179
+ title: 'T',
180
+ source: { branch: { name: 'f' } },
181
+ reviewers: [{ uuid: member(0).uuid }, { uuid: member(2).uuid }],
182
+ });
183
+ assert.match(text, /PR author\) were named as a reviewer — skipped/);
184
+ });
185
+ test('C4: ambiguous or unknown names abort before any PR is created', async () => {
186
+ const calls = stubRoutes(bitbucketRoutes());
187
+ const text = (await handleCreateBitbucketPr({
188
+ repository: 'acme/web-app',
189
+ title: 'T',
190
+ sourceBranch: 'f',
191
+ reviewers: ['Dung', 'Zed'],
192
+ })).content[0].text;
193
+ assert.equal(postOf(calls), undefined);
194
+ assert.match(text, /PR not created/);
195
+ assert.match(text, /"Dung" matches 2 members/);
196
+ assert.match(text, /Nguyễn Tiến Dũng \(@dungnt\)/);
197
+ assert.match(text, /"Zed" matches no workspace member/);
198
+ });
199
+ test('C5: a 403 on the member list names the missing scope', async () => {
200
+ stubRoutes(bitbucketRoutes((url) => (url.includes('/members') ? { status: 403, body: {} } : undefined)));
201
+ const text = (await handleCreateBitbucketPr({ repository: 'acme/web-app', title: 'T', sourceBranch: 'f', reviewers: ['Dien'] })).content[0].text;
202
+ assert.match(text, /read:workspace:bitbucket/);
203
+ });
204
+ test('C5b: the member-list scope hint is not repeated by the generic scope hint', async () => {
205
+ stubRoutes(bitbucketRoutes((url) => (url.includes('/members') ? { status: 403, body: {} } : undefined)));
206
+ const text = (await handleCreateBitbucketPr({ repository: 'acme/web-app', title: 'T', sourceBranch: 'f', reviewers: ['Dien'] })).content[0].text;
207
+ assert.doesNotMatch(text, /Check the API token has these scopes/);
208
+ });
209
+ test('C6: without read:user scope the author is not filtered, but creation proceeds', async () => {
210
+ const calls = stubRoutes(bitbucketRoutes((url) => (url.endsWith('/user') ? { status: 403, body: {} } : undefined)));
211
+ await handleCreateBitbucketPr({ repository: 'acme/web-app', title: 'T', sourceBranch: 'f', reviewers: ['Dien'] });
212
+ assert.deepEqual((postOf(calls)?.body).reviewers, [{ uuid: member(0).uuid }]);
213
+ });
214
+ test('C7: 400 surfaces the Bitbucket error message', async () => {
215
+ stubRoutes(() => ({
216
+ status: 400,
217
+ body: { type: 'error', error: { message: 'There are no changes to be pulled' } },
218
+ }));
219
+ const text = (await handleCreateBitbucketPr({ repository: 'acme/web-app', title: 'T', sourceBranch: 'f' }))
220
+ .content[0].text;
221
+ assert.match(text, /API error 400: There are no changes to be pulled/);
222
+ });
223
+ });
224
+ describe('handleFindBitbucketReviewers', () => {
225
+ test('F1: reports matched, skipped-self, ambiguous and unknown names', async () => {
226
+ stubRoutes(bitbucketRoutes());
227
+ const text = (await handleFindBitbucketReviewers({ names: ['Dien', 'Hanh', 'Dung', 'Zed'] })).content[0].text;
228
+ assert.match(text, /## Reviewer lookup \(acme\)/);
229
+ assert.match(text, /✅ Trần Văn Điền \(@dientv\) \{aaaaaaaa-0000-0000-0000-000000000001\}/);
230
+ assert.match(text, /PR author\) were named — skipped/);
231
+ assert.match(text, /"Dung" matches 2 members/);
232
+ assert.match(text, /"Zed" matches no workspace member/);
233
+ });
234
+ test('F2: an invalid workspace slug is rejected before any request', async () => {
235
+ const calls = stubRoutes(bitbucketRoutes());
236
+ const text = (await handleFindBitbucketReviewers({ names: ['Dien'], workspace: '../x' })).content[0].text;
237
+ assert.equal(calls.length, 0);
238
+ assert.match(text, /Invalid workspace slug/);
239
+ });
240
+ });
241
+ function comment(id, extra = {}) {
242
+ return {
243
+ id,
244
+ created_on: '2026-10-01T10:00:00Z',
245
+ content: { raw: `body ${id}` },
246
+ user: { display_name: `user${id}` },
247
+ deleted: false,
248
+ ...extra,
249
+ };
250
+ }
251
+ describe('handleGetBitbucketPrComments', () => {
252
+ test('M1: follows next links and threads general + inline comments', async () => {
253
+ const calls = stubFetch([
254
+ {
255
+ status: 200,
256
+ body: {
257
+ values: [comment(1), comment(2, { inline: { path: 'src/a.ts', to: 12 } })],
258
+ next: `${API}/repositories/acme/web-app/pullrequests/42/comments?pagelen=100&page=2`,
259
+ },
260
+ },
261
+ {
262
+ status: 200,
263
+ body: {
264
+ values: [
265
+ comment(3, { parent: { id: 2 }, inline: { path: 'src/a.ts', to: 12 } }),
266
+ comment(4, {
267
+ inline: { path: 'src/b.ts', from: 5 },
268
+ resolution: { type: 'comment_resolution', user: { display_name: 'Ann' } },
269
+ }),
270
+ ],
271
+ },
272
+ },
273
+ ]);
274
+ const text = (await handleGetBitbucketPrComments({ input: PR_URL })).content[0].text;
275
+ assert.equal(calls.length, 2);
276
+ assert.equal(calls[0].url, `${API}/repositories/acme/web-app/pullrequests/42/comments?pagelen=100`);
277
+ assert.match(text, /4 comments in 3 threads · 2 open · 1 resolved/);
278
+ assert.match(text, /### General\n- \*\*#1 · user1 · 2026-10-01 · 🟡 open\*\*\n > body 1/);
279
+ assert.match(text, /#### `src\/a.ts`\n- \*\*#2 · user2 · 2026-10-01 · line 12 · 🟡 open\*\*/);
280
+ assert.match(text, / ↳ \*\*#3 · user3 · 2026-10-01\*\*\n > body 3/);
281
+ assert.match(text, /#4 · user4 · 2026-10-01 · old line 5 · ✅ resolved by Ann/);
282
+ });
283
+ test('M2: includeResolved=false hides resolved threads', async () => {
284
+ stubFetch([
285
+ {
286
+ status: 200,
287
+ body: { values: [comment(1), comment(2, { resolution: { type: 'comment_resolution' } })] },
288
+ },
289
+ ]);
290
+ const text = (await handleGetBitbucketPrComments({ input: PR_URL, includeResolved: false })).content[0].text;
291
+ assert.match(text, /#1 /);
292
+ assert.doesNotMatch(text, /#2 /);
293
+ });
294
+ test('M3: deleted comments are hidden unless they carry replies', async () => {
295
+ stubFetch([
296
+ {
297
+ status: 200,
298
+ body: {
299
+ values: [comment(1, { deleted: true }), comment(2, { deleted: true }), comment(3, { parent: { id: 2 } })],
300
+ },
301
+ },
302
+ ]);
303
+ const text = (await handleGetBitbucketPrComments({ input: PR_URL })).content[0].text;
304
+ assert.doesNotMatch(text, /#1 /);
305
+ assert.match(text, /#2 [^\n]*\n > _\(deleted\)_/);
306
+ assert.match(text, /↳ \*\*#3 /);
307
+ });
308
+ test('M4: a next link to another host is never followed', async () => {
309
+ const calls = stubFetch([{ status: 200, body: { values: [comment(1)], next: 'https://evil.example/steal' } }]);
310
+ await handleGetBitbucketPrComments({ input: PR_URL });
311
+ assert.equal(calls.length, 1);
312
+ });
313
+ test('M5: stops at the page cap and says so', async () => {
314
+ const calls = stubFetch([
315
+ {
316
+ status: 200,
317
+ body: { values: [comment(1)], next: `${API}/repositories/acme/web-app/pullrequests/42/comments?page=n` },
318
+ },
319
+ ]);
320
+ const text = (await handleGetBitbucketPrComments({ input: PR_URL })).content[0].text;
321
+ assert.equal(calls.length, MAX_PAGES);
322
+ assert.match(text, /Stopped after 20 pages/);
323
+ });
324
+ test('M7: a 403 appends the scopes the tool needs', async () => {
325
+ stubFetch([{ status: 403, body: {} }]);
326
+ const text = (await handleGetBitbucketPrComments({ input: PR_URL })).content[0].text;
327
+ assert.match(text, /Access denied \(403\)/);
328
+ assert.match(text, /Check the API token has these scopes: read:pullrequest:bitbucket\./);
329
+ });
330
+ test('M6: 404 maps to a not-found message', async () => {
331
+ stubFetch([{ status: 404, body: { type: 'error', error: { message: 'nope' } } }]);
332
+ const text = (await handleGetBitbucketPrComments({ input: PR_URL })).content[0].text;
333
+ assert.match(text, /Not found/);
334
+ });
335
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,258 @@
1
+ import * as assert from 'node:assert/strict';
2
+ import * as fs from 'node:fs';
3
+ import { afterEach, describe, mock, test } from 'node:test';
4
+ import { LARGE_PAYLOAD_THRESHOLD, handleConfluenceGetPage, resolveConfluencePageId } from './integration.js';
5
+ const PAGE_ID = '123456789';
6
+ const PAGE_URL = `https://acme.atlassian.net/wiki/spaces/ENG/pages/${PAGE_ID}/Some+Page+Title`;
7
+ const BLOG_URL = 'https://acme.atlassian.net/wiki/spaces/ENG/blog/2026/01/15/987654321/Post+Title';
8
+ const TINY_URL = 'https://acme.atlassian.net/wiki/x/AbCdEf';
9
+ // Credentials resolve through process.env first, which is enough for these tests: every
10
+ // case here supplies credentials rather than asserting their absence.
11
+ process.env.ATLASSIAN_CLOUD_ID = 'cloud-a';
12
+ process.env.ATLASSIAN_USER_EMAIL = 'dev@acme.test';
13
+ process.env.CONFLUENCE_API_TOKEN = 'confluence-token';
14
+ /** Replace the global fetch for one test; `afterEach` restores it. */
15
+ function stubFetch(status, body) {
16
+ const calls = [];
17
+ const authHeaders = [];
18
+ mock.method(globalThis, 'fetch', async (input, init) => {
19
+ calls.push(String(input));
20
+ authHeaders.push(String(init?.headers?.Authorization ?? ''));
21
+ return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
22
+ status,
23
+ headers: { 'content-type': 'application/json' },
24
+ });
25
+ });
26
+ return { calls, authHeaders };
27
+ }
28
+ const spilledFiles = [];
29
+ afterEach(() => {
30
+ mock.restoreAll();
31
+ while (spilledFiles.length > 0) {
32
+ const file = spilledFiles.pop();
33
+ if (file)
34
+ fs.rmSync(file, { force: true });
35
+ }
36
+ });
37
+ /** Decode the `email:token` pair a Basic auth header carries. */
38
+ function decodeBasicAuth(header) {
39
+ return Buffer.from(header.replace('Basic ', ''), 'base64').toString('utf8');
40
+ }
41
+ function pageFixture(overrides = {}) {
42
+ return {
43
+ id: PAGE_ID,
44
+ title: 'Design Notes',
45
+ status: 'current',
46
+ spaceId: '98765',
47
+ version: { number: 7, createdAt: '2026-07-01T10:00:00.000Z' },
48
+ body: { storage: { value: '<p>Hello <strong>world</strong></p>' } },
49
+ labels: { results: [{ name: 'design' }, { name: 'api' }] },
50
+ _links: { base: 'https://acme.atlassian.net/wiki', webui: `/spaces/ENG/pages/${PAGE_ID}/Design+Notes` },
51
+ ...overrides,
52
+ };
53
+ }
54
+ async function textFor(input) {
55
+ return (await handleConfluenceGetPage({ input })).content[0].text;
56
+ }
57
+ async function successText(page) {
58
+ stubFetch(200, page);
59
+ return textFor(PAGE_URL);
60
+ }
61
+ describe('resolveConfluencePageId (BC2, BC5, BC6, BC7)', () => {
62
+ test('H1: full page URL with a title segment resolves to the numeric id', () => {
63
+ assert.deepEqual(resolveConfluencePageId(PAGE_URL), { kind: 'id', pageId: PAGE_ID });
64
+ });
65
+ test('H2: page URL with a query string and fragment but no title segment resolves', () => {
66
+ const url = `https://acme.atlassian.net/wiki/spaces/ENG/pages/${PAGE_ID}?focusedCommentId=99#anchor`;
67
+ assert.deepEqual(resolveConfluencePageId(url), { kind: 'id', pageId: PAGE_ID });
68
+ });
69
+ test('H3: legacy viewpage.action form resolves from the pageId query parameter', () => {
70
+ const url = `https://acme.atlassian.net/pages/viewpage.action?pageId=${PAGE_ID}`;
71
+ assert.deepEqual(resolveConfluencePageId(url), { kind: 'id', pageId: PAGE_ID });
72
+ });
73
+ test('H4: bare numeric id resolves', () => {
74
+ assert.deepEqual(resolveConfluencePageId(PAGE_ID), { kind: 'id', pageId: PAGE_ID });
75
+ });
76
+ test('H8: blog URL is classified as blog, never as an id from its date segments', () => {
77
+ const resolution = resolveConfluencePageId(BLOG_URL);
78
+ assert.equal(resolution.kind, 'blog');
79
+ assert.notEqual(resolution.kind, 'id');
80
+ const serialised = JSON.stringify(resolution);
81
+ assert.ok(!serialised.includes('2026'), 'a date segment must never become a page id');
82
+ assert.ok(!serialised.includes('987654321'), 'a blog post id must never become a page id');
83
+ });
84
+ });
85
+ describe('handleConfluenceGetPage — pre-network error paths (BC5, BC6, BC7, BC8)', () => {
86
+ test('H5: tiny link errors before any network call and asks for the full page URL', async () => {
87
+ assert.deepEqual(resolveConfluencePageId(TINY_URL), { kind: 'tiny' });
88
+ const { calls } = stubFetch(200, pageFixture());
89
+ const text = await textFor(TINY_URL);
90
+ assert.equal(calls.length, 0, 'MUST NOT call fetch for a tiny link');
91
+ assert.match(text, /tiny link/i);
92
+ assert.match(text, /full page URL/i);
93
+ });
94
+ test('H6: blog URL errors before any network call', async () => {
95
+ const { calls } = stubFetch(200, pageFixture());
96
+ const text = await textFor(BLOG_URL);
97
+ assert.equal(calls.length, 0, 'MUST NOT call fetch for a blog URL');
98
+ assert.match(text, /blog posts are not supported/i);
99
+ });
100
+ test('H7: unrecognised input errors before any network call and lists accepted forms', async () => {
101
+ assert.deepEqual(resolveConfluencePageId('not a confluence link at all'), { kind: 'unknown' });
102
+ const { calls } = stubFetch(200, pageFixture());
103
+ const text = await textFor('not a confluence link at all');
104
+ assert.equal(calls.length, 0, 'MUST NOT call fetch for unrecognised input');
105
+ assert.match(text, /Accepted input forms/);
106
+ assert.match(text, /viewpage\.action\?pageId=/);
107
+ assert.match(text, /numeric page ID/);
108
+ });
109
+ });
110
+ describe('handleConfluenceGetPage — happy path output (BC3)', () => {
111
+ test('H13: renders the full brief in exactly one request', async () => {
112
+ const { calls, authHeaders } = stubFetch(200, pageFixture());
113
+ const text = await textFor(PAGE_URL);
114
+ assert.equal(calls.length, 1, 'exactly one HTTP request — no space or user lookup');
115
+ assert.ok(calls[0].includes('/ex/confluence/cloud-a/'), `must use the shared cloud id, got: ${calls[0]}`);
116
+ assert.equal(decodeBasicAuth(authHeaders[0]), 'dev@acme.test:confluence-token', 'Confluence must authenticate with the shared email and its own token, not the Jira token');
117
+ assert.ok(calls[0].includes('body-format=storage'), `got: ${calls[0]}`);
118
+ assert.ok(calls[0].includes('include-labels=true'), `got: ${calls[0]}`);
119
+ assert.ok(calls[0].includes('include-version=true'), `got: ${calls[0]}`);
120
+ assert.match(text, /Design Notes/);
121
+ assert.match(text, /\*\*Status:\*\* current/);
122
+ assert.match(text, /\*\*Space:\*\* ENG/);
123
+ assert.match(text, /\*\*Version:\*\* 7/);
124
+ assert.match(text, /\*\*Last updated:\*\* 2026-07-01T10:00:00\.000Z/);
125
+ assert.match(text, new RegExp(`\\*\\*URL:\\*\\* https://acme\\.atlassian\\.net/wiki/spaces/ENG/pages/${PAGE_ID}/Design\\+Notes`));
126
+ assert.match(text, /### Content\nHello \*\*world\*\*/);
127
+ assert.match(text, /### Labels\ndesign, api/);
128
+ });
129
+ test('H14: space line is omitted, never guessed, when webui has no /spaces/<KEY>/', async () => {
130
+ const text = await successText(pageFixture({ _links: { base: 'https://acme.atlassian.net/wiki', webui: '/foo/bar' } }));
131
+ assert.ok(!text.includes('**Space:**'), `space line must be omitted, got: ${text}`);
132
+ assert.ok(!text.includes('98765'), 'spaceId must never be presented as a space key');
133
+ assert.match(text, /\*\*URL:\*\* https:\/\/acme\.atlassian\.net\/wiki\/foo\/bar/);
134
+ });
135
+ });
136
+ describe('handleConfluenceGetPage — distinct HTTP and schema errors (BC9)', () => {
137
+ async function errorText(status, body = { message: 'nope' }) {
138
+ stubFetch(status, body);
139
+ return textFor(PAGE_URL);
140
+ }
141
+ test('H15: 401 identifies an auth failure', async () => {
142
+ const text = await errorText(401);
143
+ assert.match(text, /401/);
144
+ assert.match(text, /Auth failed/i);
145
+ assert.ok(!/Not found/i.test(text), '401 must be distinct from the 404 message');
146
+ });
147
+ test('H16: 403 mentions permission or restriction and is distinct from 404', async () => {
148
+ const text = await errorText(403);
149
+ assert.match(text, /403/);
150
+ assert.match(text, /permission|restricted/i);
151
+ assert.ok(!/Not found/i.test(text), '403 must be distinct from the 404 message');
152
+ });
153
+ test('H17: 404 identifies not-found and is distinct from 403', async () => {
154
+ const text = await errorText(404);
155
+ assert.match(text, /Not found/i);
156
+ assert.ok(!text.includes('403'), '404 must be distinct from the 403 message');
157
+ });
158
+ test('H18: other non-OK statuses include the status code', async () => {
159
+ const text = await errorText(500);
160
+ assert.match(text, /500/);
161
+ assert.ok(!/Auth failed/i.test(text), '500 must be distinct from 401');
162
+ assert.ok(!/Not found/i.test(text), '500 must be distinct from 404');
163
+ assert.ok(!/permission|restricted/i.test(text), '500 must be distinct from 403');
164
+ });
165
+ test('H19: a 200 missing title is reported as an invalid response format', async () => {
166
+ const { title: _title, ...withoutTitle } = pageFixture();
167
+ const text = await successText(withoutTitle);
168
+ assert.match(text, /Invalid Confluence response format/);
169
+ assert.ok(!text.includes('### Content'), 'a schema failure must not be presented as partial success');
170
+ });
171
+ });
172
+ describe('handleConfluenceGetPage — empty and unconvertible bodies (BC10)', () => {
173
+ const EXPECTED_URL = `https://acme.atlassian.net/wiki/spaces/ENG/pages/${PAGE_ID}/Design+Notes`;
174
+ function assertEmptyWarning(text, status) {
175
+ assert.match(text, /⚠️ Confluence returned no readable body/);
176
+ assert.ok(text.includes(`status: ${status}`), `warning must name the page status, got: ${text}`);
177
+ assert.ok(text.includes(EXPECTED_URL), `warning must include the absolute URL, got: ${text}`);
178
+ assert.ok(!/### Content\n\s*\n/.test(text), 'MUST NOT emit an empty Content section as if it were success');
179
+ }
180
+ test('H20: body: {} (the documented Atlassian defect) warns explicitly', async () => {
181
+ assertEmptyWarning(await successText(pageFixture({ body: {} })), 'current');
182
+ });
183
+ test('H21: an empty storage value warns explicitly', async () => {
184
+ assertEmptyWarning(await successText(pageFixture({ body: { storage: { value: '' } } })), 'current');
185
+ });
186
+ test('H22: a body that converts to whitespace only warns explicitly', async () => {
187
+ assertEmptyWarning(await successText(pageFixture({ body: { storage: { value: '<p>&nbsp;</p>' } } })), 'current');
188
+ });
189
+ test('H23: a trashed page names its status in the warning', async () => {
190
+ const text = await successText(pageFixture({ status: 'trashed', body: {} }));
191
+ assertEmptyWarning(text, 'trashed');
192
+ assert.match(text, /\*\*Status:\*\* trashed/);
193
+ });
194
+ });
195
+ describe('handleConfluenceGetPage — oversize bodies spill to a temp file (BC11)', () => {
196
+ const HUGE_TEXT = 'x'.repeat(LARGE_PAYLOAD_THRESHOLD + 10);
197
+ test('H24: the body is written to /tmp and referenced, not inlined', async () => {
198
+ stubFetch(200, pageFixture({ body: { storage: { value: `<p>${HUGE_TEXT}</p>` } } }));
199
+ const text = await textFor(PAGE_URL);
200
+ const spilledPath = text.match(new RegExp(`/tmp/kit-confluence-${PAGE_ID}-\\d+\\.md`))?.[0];
201
+ assert.ok(spilledPath, `output must reference a temp path, got: ${text.slice(0, 300)}`);
202
+ spilledFiles.push(spilledPath);
203
+ assert.ok(fs.existsSync(spilledPath), 'the temp file must actually be written');
204
+ assert.ok(fs.readFileSync(spilledPath, 'utf8').includes(HUGE_TEXT), 'the temp file must hold the markdown');
205
+ assert.ok(!text.includes(HUGE_TEXT), 'MUST NOT inline the body once it has been spilled');
206
+ });
207
+ });
208
+ describe('handleConfluenceGetPage — secret redaction and labels (BC19, BC20)', () => {
209
+ test('H26: a pasted AWS key in the page body is redacted', async () => {
210
+ const secret = 'AKIAABCDEFGHIJKLMNOP';
211
+ const text = await successText(pageFixture({ body: { storage: { value: `<p>key ${secret} here</p>` } } }));
212
+ assert.match(text, /\[REDACTED\]/);
213
+ assert.ok(!text.includes(secret), 'the raw token MUST NOT reach the agent context');
214
+ });
215
+ test('H27: absent labels render None', async () => {
216
+ const { labels: _labels, ...withoutLabels } = pageFixture();
217
+ assert.match(await successText(withoutLabels), /### Labels\nNone/);
218
+ });
219
+ test('H28: an empty labels result renders None', async () => {
220
+ assert.match(await successText(pageFixture({ labels: { results: [] } })), /### Labels\nNone/);
221
+ });
222
+ test('H29: multiple labels render comma-joined', async () => {
223
+ assert.match(await successText(pageFixture()), /### Labels\ndesign, api/);
224
+ });
225
+ });
226
+ describe('handleConfluenceGetPage — never rejects (H30)', () => {
227
+ test('H30: every error and success scenario resolves to a valid mcpText envelope', async () => {
228
+ const scenarios = [
229
+ { label: 'tiny link', input: TINY_URL, status: 200, body: pageFixture() },
230
+ { label: 'blog URL', input: BLOG_URL, status: 200, body: pageFixture() },
231
+ { label: 'unrecognised', input: 'nonsense', status: 200, body: pageFixture() },
232
+ ...[401, 403, 404, 500].map((status) => ({
233
+ label: `HTTP ${status}`,
234
+ input: PAGE_URL,
235
+ status,
236
+ body: { message: 'nope' },
237
+ })),
238
+ { label: 'schema mismatch', input: PAGE_URL, status: 200, body: { id: PAGE_ID } },
239
+ { label: 'empty body', input: PAGE_URL, status: 200, body: pageFixture({ body: {} }) },
240
+ {
241
+ label: 'malformed body markup',
242
+ input: PAGE_URL,
243
+ status: 200,
244
+ body: pageFixture({ body: { storage: { value: '<p><strong>oops</p><![CDATA[' } } }),
245
+ },
246
+ { label: 'happy path', input: PAGE_URL, status: 200, body: pageFixture() },
247
+ ];
248
+ for (const scenario of scenarios) {
249
+ stubFetch(scenario.status, scenario.body);
250
+ const result = await handleConfluenceGetPage({ input: scenario.input });
251
+ assert.equal(result.content.length, 1, `${scenario.label}: expected one content entry`);
252
+ assert.equal(result.content[0].type, 'text', `${scenario.label}: expected a text envelope`);
253
+ assert.equal(typeof result.content[0].text, 'string', `${scenario.label}: expected string text`);
254
+ assert.ok(result.content[0].text.length > 0, `${scenario.label}: expected a non-empty message`);
255
+ mock.restoreAll();
256
+ }
257
+ });
258
+ });