Add gateway-first pattern: when AGILITON_API_KEY is set, route all external API calls through the gateway with X-API-Key auth. Falls back to direct API access when gateway is unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
6.8 KiB
JavaScript
210 lines
6.8 KiB
JavaScript
/**
|
|
* Confluence Cloud REST API v2 client — routes through AgilitonAPI gateway.
|
|
* Falls back to direct Confluence access if AGILITON_API_KEY is not set.
|
|
*
|
|
* Gateway: AGILITON_API_KEY + AGILITON_API_URL
|
|
* Direct: CONFLUENCE_USER/JIRA_USER + CONFLUENCE_API_TOKEN/JIRA_API_TOKEN
|
|
*/
|
|
// Gateway config
|
|
const GATEWAY_URL = (process.env.AGILITON_API_URL || 'https://api.agiliton.cloud').replace(/\/$/, '');
|
|
const GATEWAY_KEY = process.env.AGILITON_API_KEY || '';
|
|
// Direct config (fallback)
|
|
const BASE_URL = process.env.CONFLUENCE_URL || 'https://agiliton.atlassian.net/wiki';
|
|
const API_V2 = `${BASE_URL}/api/v2`;
|
|
const API_V1 = `${BASE_URL}/rest/api`;
|
|
function useGateway() {
|
|
return !!GATEWAY_KEY;
|
|
}
|
|
function getAuth() {
|
|
const user = process.env.CONFLUENCE_USER || process.env.JIRA_USER;
|
|
const token = process.env.CONFLUENCE_API_TOKEN || process.env.JIRA_API_TOKEN;
|
|
if (!user || !token) {
|
|
throw new Error('CONFLUENCE_USER and CONFLUENCE_API_TOKEN (or JIRA_USER/JIRA_API_TOKEN) must be set');
|
|
}
|
|
return Buffer.from(`${user}:${token}`).toString('base64');
|
|
}
|
|
async function request(url, options = {}) {
|
|
let finalUrl = url;
|
|
const headers = {
|
|
'Accept': 'application/json',
|
|
...(options.headers || {}),
|
|
};
|
|
if (useGateway()) {
|
|
// Route through gateway — translate Confluence URLs to gateway paths
|
|
finalUrl = toGatewayUrl(url);
|
|
headers['X-API-Key'] = GATEWAY_KEY;
|
|
}
|
|
else {
|
|
headers['Authorization'] = `Basic ${getAuth()}`;
|
|
}
|
|
if (options.body && typeof options.body === 'string') {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
const response = await fetch(finalUrl, { ...options, headers });
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(`Confluence API ${response.status}: ${text.slice(0, 500)}`);
|
|
}
|
|
if (response.status === 204)
|
|
return null;
|
|
return response.json();
|
|
}
|
|
/**
|
|
* Translate a direct Confluence URL to a gateway URL.
|
|
* /wiki/api/v2/spaces → /confluence/spaces
|
|
* /wiki/rest/api/content/search → /confluence/search
|
|
* /wiki/rest/api/space → /confluence/space
|
|
*/
|
|
function toGatewayUrl(url) {
|
|
// Extract path from full URL
|
|
let path = url;
|
|
if (path.startsWith('http')) {
|
|
const u = new URL(path);
|
|
path = u.pathname + u.search;
|
|
}
|
|
// v2 API: /wiki/api/v2/... → /confluence/...
|
|
const v2Match = path.match(/\/api\/v2\/(.*)$/);
|
|
if (v2Match)
|
|
return `${GATEWAY_URL}/confluence/${v2Match[1]}`;
|
|
// v1 API: /wiki/rest/api/content/search → /confluence/search
|
|
const searchMatch = path.match(/\/rest\/api\/content\/search(.*)$/);
|
|
if (searchMatch)
|
|
return `${GATEWAY_URL}/confluence/search${searchMatch[1]}`;
|
|
// v1 API: /wiki/rest/api/space → /confluence/space
|
|
const spaceMatch = path.match(/\/rest\/api\/space(.*)$/);
|
|
if (spaceMatch)
|
|
return `${GATEWAY_URL}/confluence/space${spaceMatch[1]}`;
|
|
// Fallback: strip /wiki prefix and use as-is
|
|
const stripped = path.replace(/^\/wiki/, '');
|
|
return `${GATEWAY_URL}/confluence${stripped}`;
|
|
}
|
|
// --- Spaces ---
|
|
export async function listSpaces(limit = 25) {
|
|
const data = await request(`${API_V2}/spaces?limit=${limit}&sort=name`);
|
|
return data.results;
|
|
}
|
|
export async function getSpace(spaceIdOrKey) {
|
|
return request(`${API_V2}/spaces/${spaceIdOrKey}`);
|
|
}
|
|
export async function createSpace(key, name, description) {
|
|
// v2 doesn't have space creation — use v1
|
|
const body = {
|
|
key,
|
|
name,
|
|
type: 'global',
|
|
};
|
|
if (description) {
|
|
body.description = {
|
|
plain: { value: description, representation: 'plain' },
|
|
};
|
|
}
|
|
return request(`${API_V1}/space`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
// --- Pages ---
|
|
export async function searchPages(query, spaceId, limit = 10) {
|
|
let cql = `type=page AND text ~ "${query.replace(/"/g, '\\"')}"`;
|
|
if (spaceId)
|
|
cql += ` AND space.id=${spaceId}`;
|
|
cql += ' ORDER BY lastmodified DESC';
|
|
const params = new URLSearchParams({
|
|
cql,
|
|
limit: String(limit),
|
|
});
|
|
// CQL search is v1 only
|
|
const data = await request(`${API_V1}/content/search?${params}`);
|
|
return data.results.map((r) => ({
|
|
id: r.id,
|
|
status: r.status,
|
|
title: r.title,
|
|
spaceId: r.space?.id || '',
|
|
authorId: r.history?.createdBy?.accountId || '',
|
|
createdAt: r.history?.createdDate || '',
|
|
version: {
|
|
number: r.version?.number || 1,
|
|
createdAt: r.version?.when || '',
|
|
},
|
|
_links: {
|
|
webui: r._links?.webui ? `${BASE_URL}${r._links.webui}` : undefined,
|
|
},
|
|
}));
|
|
}
|
|
export async function getPage(pageId, includeBody = true) {
|
|
const params = includeBody ? '?body-format=storage' : '';
|
|
return request(`${API_V2}/pages/${pageId}${params}`);
|
|
}
|
|
export async function getPageByTitle(spaceId, title) {
|
|
const params = new URLSearchParams({
|
|
title,
|
|
'space-id': spaceId,
|
|
'body-format': 'storage',
|
|
limit: '1',
|
|
});
|
|
const data = await request(`${API_V2}/pages?${params}`);
|
|
return data.results?.[0] || null;
|
|
}
|
|
export async function createPage(spaceId, title, body, parentId) {
|
|
const payload = {
|
|
spaceId,
|
|
status: 'current',
|
|
title,
|
|
body: {
|
|
representation: 'storage',
|
|
value: body,
|
|
},
|
|
};
|
|
if (parentId)
|
|
payload.parentId = parentId;
|
|
return request(`${API_V2}/pages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
export async function updatePage(pageId, title, body, versionNumber, versionMessage) {
|
|
const payload = {
|
|
id: pageId,
|
|
status: 'current',
|
|
title,
|
|
body: {
|
|
representation: 'storage',
|
|
value: body,
|
|
},
|
|
version: {
|
|
number: versionNumber,
|
|
message: versionMessage || 'Updated via Claude',
|
|
},
|
|
};
|
|
return request(`${API_V2}/pages/${pageId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
// --- Comments ---
|
|
export async function getPageComments(pageId, limit = 25) {
|
|
const data = await request(`${API_V2}/pages/${pageId}/footer-comments?body-format=storage&limit=${limit}`);
|
|
return data.results;
|
|
}
|
|
export async function addPageComment(pageId, body) {
|
|
return request(`${API_V2}/footer-comments`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
pageId,
|
|
body: {
|
|
representation: 'storage',
|
|
value: body,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
// --- Utility ---
|
|
export function getWebUrl(page) {
|
|
if (page._links?.webui) {
|
|
return page._links.webui.startsWith('http')
|
|
? page._links.webui
|
|
: `${BASE_URL}${page._links.webui}`;
|
|
}
|
|
return `${BASE_URL}/pages/${page.id}`;
|
|
}
|