Confluence Integration for Documentation
Automate documentation with Confluence and Azure DevOps integration for release notes, sprint reports, and deployment runbooks
Confluence Integration for Documentation
Keeping documentation in sync with what your team actually builds is one of the hardest problems in software engineering. Confluence sits at the center of most enterprise documentation strategies, and Azure DevOps holds the ground truth about what was planned, built, and deployed. Connecting these two systems eliminates the manual busywork of writing release notes, updating runbooks, and generating sprint reports — and it ensures your documentation is never stale.
Prerequisites
Before diving in, you need:
- Node.js 18+ installed locally
- An Atlassian Cloud instance with Confluence (or Confluence Data Center)
- An Azure DevOps organization with at least one project containing work items and pipelines
- An Atlassian API token (generate at id.atlassian.com/manage-profile/security/api-tokens)
- An Azure DevOps Personal Access Token (PAT) with read access to work items, builds, and releases
- Basic familiarity with REST APIs and Express.js
Install the dependencies we will use throughout this article:
npm init -y
npm install axios express node-cron [email protected]
Why Integrate Confluence with Azure DevOps
Most teams treat documentation as an afterthought. Someone writes a release note at 5 PM on a Friday, copy-pasting JIRA titles into a Confluence page. Sprint retrospectives get documented weeks later from fuzzy memories. Deployment runbooks drift from reality after the second hotfix.
The integration solves three concrete problems:
- Accuracy — Documentation is generated from the source of truth (work items, build artifacts, deployment logs), not from someone's recollection.
- Timeliness — Pages are published automatically when events happen (a release completes, a sprint ends, a deployment succeeds).
- Consistency — Every release note, every retrospective, every runbook follows the same template and contains the same level of detail.
I have seen teams go from zero documentation discipline to full audit-ready release trails in under a week by wiring these two systems together.
Confluence REST API Basics
Confluence exposes a comprehensive REST API. The two endpoints you will use most are:
POST /wiki/rest/api/content— Create a new pagePUT /wiki/rest/api/content/{id}— Update an existing page
Authentication uses Basic Auth with your email and API token. Here is a minimal client:
var axios = require("axios");
function createConfluenceClient(baseUrl, email, apiToken) {
var auth = Buffer.from(email + ":" + apiToken).toString("base64");
var client = axios.create({
baseURL: baseUrl + "/wiki/rest/api",
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json",
"Accept": "application/json"
}
});
return {
createPage: function(spaceKey, title, body, parentId) {
var payload = {
type: "page",
title: title,
space: { key: spaceKey },
body: {
storage: {
value: body,
representation: "storage"
}
}
};
if (parentId) {
payload.ancestors = [{ id: parentId }];
}
return client.post("/content", payload);
},
updatePage: function(pageId, title, body, version) {
return client.put("/content/" + pageId, {
type: "page",
title: title,
body: {
storage: {
value: body,
representation: "storage"
}
},
version: { number: version }
});
},
getPage: function(pageId) {
return client.get("/content/" + pageId + "?expand=version,body.storage");
},
findPage: function(spaceKey, title) {
var cql = 'space="' + spaceKey + '" AND title="' + title + '"';
return client.get("/content", { params: { cql: cql } });
}
};
}
module.exports = createConfluenceClient;
Confluence uses a storage format that resembles XHTML. You cannot just dump raw HTML — certain elements and attributes are required. The <ac:structured-macro> tags let you embed Confluence-specific widgets like status badges and panels.
Azure DevOps API Client
Similarly, you need a client for Azure DevOps. The work items API and build API are the two you will use most:
var axios = require("axios");
function createAzureDevOpsClient(orgUrl, project, pat) {
var auth = Buffer.from(":" + pat).toString("base64");
var client = axios.create({
baseURL: orgUrl + "/" + project + "/_apis",
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json"
},
params: {
"api-version": "7.1"
}
});
return {
getWorkItem: function(id) {
return client.get("/wit/workitems/" + id + "?$expand=relations");
},
queryWorkItems: function(wiql) {
return client.post("/wit/wiql", { query: wiql });
},
getBuild: function(buildId) {
return client.get("/build/builds/" + buildId);
},
getRelease: function(releaseId) {
return axios.get(
orgUrl + "/" + project + "/_apis/release/releases/" + releaseId,
{
headers: { "Authorization": "Basic " + auth },
params: { "api-version": "7.1" }
}
);
},
getIterationWorkItems: function(iterationPath) {
var query = "SELECT [System.Id], [System.Title], [System.State], " +
"[System.WorkItemType], [System.AssignedTo] " +
"FROM WorkItems WHERE [System.IterationPath] = '" + iterationPath + "' " +
"ORDER BY [System.WorkItemType]";
return client.post("/wit/wiql", { query: query });
}
};
}
module.exports = createAzureDevOpsClient;
Publishing Release Notes from Pipelines
The most common integration is generating release notes when a build or release completes. You query Azure DevOps for work items associated with the build, format them into Confluence storage markup, and publish a page.
var createConfluenceClient = require("./confluenceClient");
var createAzureDevOpsClient = require("./azureDevOpsClient");
function generateReleaseNotes(workItems, buildInfo) {
var now = new Date().toISOString().split("T")[0];
var html = '<ac:structured-macro ac:name="info">' +
"<ac:rich-text-body><p>Release <strong>" + buildInfo.buildNumber +
"</strong> deployed on " + now + "</p></ac:rich-text-body>" +
"</ac:structured-macro>";
var grouped = { "Bug": [], "User Story": [], "Task": [] };
workItems.forEach(function(item) {
var type = item.fields["System.WorkItemType"] || "Task";
if (!grouped[type]) grouped[type] = [];
grouped[type].push(item);
});
Object.keys(grouped).forEach(function(type) {
if (grouped[type].length === 0) return;
html += "<h2>" + type + "s</h2><table><tr><th>ID</th><th>Title</th>" +
"<th>State</th><th>Assigned To</th></tr>";
grouped[type].forEach(function(item) {
var assignedTo = item.fields["System.AssignedTo"];
var name = assignedTo ? assignedTo.displayName : "Unassigned";
html += "<tr><td>" + item.id + "</td><td>" + item.fields["System.Title"] +
"</td><td>" + item.fields["System.State"] + "</td><td>" + name +
"</td></tr>";
});
html += "</table>";
});
return html;
}
async function publishReleaseNotes(buildId) {
var ado = createAzureDevOpsClient(
process.env.ADO_ORG_URL,
process.env.ADO_PROJECT,
process.env.ADO_PAT
);
var confluence = createConfluenceClient(
process.env.CONFLUENCE_BASE_URL,
process.env.CONFLUENCE_EMAIL,
process.env.CONFLUENCE_API_TOKEN
);
var buildResponse = await ado.getBuild(buildId);
var build = buildResponse.data;
// Query work items associated with this build
var wiql = "SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS '" +
build.buildNumber + "'";
var queryResult = await ado.queryWorkItems(wiql);
var workItemIds = queryResult.data.workItems.map(function(wi) { return wi.id; });
var workItems = [];
for (var i = 0; i < workItemIds.length; i++) {
var response = await ado.getWorkItem(workItemIds[i]);
workItems.push(response.data);
}
var body = generateReleaseNotes(workItems, build);
var title = "Release Notes - " + build.buildNumber;
await confluence.createPage(
process.env.CONFLUENCE_SPACE_KEY,
title,
body,
process.env.CONFLUENCE_PARENT_PAGE_ID
);
console.log("Published release notes: " + title);
}
To trigger this from an Azure DevOps pipeline, add a task at the end of your release pipeline that calls your service:
- task: PowerShell@2
displayName: 'Publish Release Notes to Confluence'
inputs:
targetType: 'inline'
script: |
$body = @{ buildId = "$(Build.BuildId)" } | ConvertTo-Json
Invoke-RestMethod -Uri "$(DOC_SERVICE_URL)/api/release-notes" -Method POST -Body $body -ContentType "application/json"
Automated Documentation from Work Items
Beyond release notes, you can generate documentation pages directly from work items. Epics and features often contain acceptance criteria and descriptions that serve as functional specifications. Pulling these into Confluence creates a living spec document.
async function syncEpicDocumentation(epicId) {
var ado = createAzureDevOpsClient(
process.env.ADO_ORG_URL,
process.env.ADO_PROJECT,
process.env.ADO_PAT
);
var confluence = createConfluenceClient(
process.env.CONFLUENCE_BASE_URL,
process.env.CONFLUENCE_EMAIL,
process.env.CONFLUENCE_API_TOKEN
);
var epicResponse = await ado.getWorkItem(epicId);
var epic = epicResponse.data;
var childQuery = "SELECT [System.Id], [System.Title], [System.State], " +
"[Microsoft.VSTS.Common.AcceptanceCriteria] " +
"FROM WorkItemLinks WHERE [Source].[System.Id] = " + epicId +
" AND [System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Forward'";
var childResult = await ado.queryWorkItems(childQuery);
var html = "<h1>" + epic.fields["System.Title"] + "</h1>";
html += "<p>" + (epic.fields["System.Description"] || "No description") + "</p>";
html += '<ac:structured-macro ac:name="toc" />';
html += "<h2>Features</h2>";
for (var i = 0; i < childResult.data.workItemRelations.length; i++) {
var relation = childResult.data.workItemRelations[i];
if (!relation.target) continue;
var featureResponse = await ado.getWorkItem(relation.target.id);
var feature = featureResponse.data;
html += "<h3>" + feature.fields["System.Title"] + "</h3>";
html += '<ac:structured-macro ac:name="status">' +
'<ac:parameter ac:name="title">' + feature.fields["System.State"] +
"</ac:parameter></ac:structured-macro>";
var criteria = feature.fields["Microsoft.VSTS.Common.AcceptanceCriteria"];
if (criteria) {
html += "<h4>Acceptance Criteria</h4>" + criteria;
}
}
var pageTitle = "Epic: " + epic.fields["System.Title"];
var existing = await confluence.findPage(process.env.CONFLUENCE_SPACE_KEY, pageTitle);
if (existing.data.results.length > 0) {
var page = existing.data.results[0];
var pageDetail = await confluence.getPage(page.id);
var currentVersion = pageDetail.data.version.number;
await confluence.updatePage(page.id, pageTitle, html, currentVersion + 1);
console.log("Updated: " + pageTitle);
} else {
await confluence.createPage(
process.env.CONFLUENCE_SPACE_KEY,
pageTitle,
html,
process.env.CONFLUENCE_PARENT_PAGE_ID
);
console.log("Created: " + pageTitle);
}
}
Linking Confluence Pages to Azure DevOps Work Items
Azure DevOps supports hyperlinks on work items. After you publish a Confluence page, you can attach its URL back to the originating work item so developers can find the documentation directly from their board:
var axios = require("axios");
function linkConfluenceToWorkItem(orgUrl, project, pat, workItemId, confluenceUrl, pageTitle) {
var auth = Buffer.from(":" + pat).toString("base64");
var patchBody = [
{
op: "add",
path: "/relations/-",
value: {
rel: "Hyperlink",
url: confluenceUrl,
attributes: {
comment: "Confluence: " + pageTitle
}
}
}
];
return axios.patch(
orgUrl + "/" + project + "/_apis/wit/workitems/" + workItemId + "?api-version=7.1",
patchBody,
{
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json-patch+json"
}
}
);
}
This bidirectional linking is critical. Engineers see the docs from DevOps; product managers see the work items from Confluence.
Sprint Retrospective Reports
Automating sprint retrospectives saves a surprising amount of time. You can pull completed and incomplete work items for an iteration, calculate velocity, and generate a structured report:
async function generateSprintReport(iterationPath) {
var ado = createAzureDevOpsClient(
process.env.ADO_ORG_URL,
process.env.ADO_PROJECT,
process.env.ADO_PAT
);
var result = await ado.getIterationWorkItems(iterationPath);
var ids = result.data.workItems.map(function(wi) { return wi.id; });
var completed = [];
var incomplete = [];
var totalPoints = 0;
var completedPoints = 0;
for (var i = 0; i < ids.length; i++) {
var response = await ado.getWorkItem(ids[i]);
var item = response.data;
var points = item.fields["Microsoft.VSTS.Scheduling.StoryPoints"] || 0;
totalPoints += points;
if (item.fields["System.State"] === "Closed" || item.fields["System.State"] === "Done") {
completed.push(item);
completedPoints += points;
} else {
incomplete.push(item);
}
}
var velocity = totalPoints > 0 ? Math.round((completedPoints / totalPoints) * 100) : 0;
var html = '<ac:structured-macro ac:name="panel">' +
'<ac:parameter ac:name="title">Sprint Summary</ac:parameter>' +
"<ac:rich-text-body>" +
"<p><strong>Iteration:</strong> " + iterationPath + "</p>" +
"<p><strong>Total Items:</strong> " + ids.length + "</p>" +
"<p><strong>Completed:</strong> " + completed.length + "</p>" +
"<p><strong>Incomplete:</strong> " + incomplete.length + "</p>" +
"<p><strong>Velocity:</strong> " + completedPoints + " / " + totalPoints +
" points (" + velocity + "%)</p>" +
"</ac:rich-text-body></ac:structured-macro>";
html += "<h2>Completed Work</h2><table><tr><th>ID</th><th>Type</th>" +
"<th>Title</th><th>Points</th></tr>";
completed.forEach(function(item) {
var points = item.fields["Microsoft.VSTS.Scheduling.StoryPoints"] || "-";
html += "<tr><td>" + item.id + "</td><td>" +
item.fields["System.WorkItemType"] + "</td><td>" +
item.fields["System.Title"] + "</td><td>" + points + "</td></tr>";
});
html += "</table>";
if (incomplete.length > 0) {
html += '<h2>Carried Over</h2><ac:structured-macro ac:name="warning">' +
"<ac:rich-text-body><p>The following items were not completed and will carry " +
"over to the next sprint.</p></ac:rich-text-body></ac:structured-macro>";
html += "<table><tr><th>ID</th><th>Type</th><th>Title</th><th>State</th></tr>";
incomplete.forEach(function(item) {
html += "<tr><td>" + item.id + "</td><td>" +
item.fields["System.WorkItemType"] + "</td><td>" +
item.fields["System.Title"] + "</td><td>" +
item.fields["System.State"] + "</td></tr>";
});
html += "</table>";
}
return html;
}
Architecture Decision Records
ADRs are one of the most underrated documentation practices. You can create a pipeline task that generates an ADR template in Confluence whenever a specific work item tag is applied:
function generateADRPage(title, context, decision, consequences) {
var date = new Date().toISOString().split("T")[0];
var html = '<ac:structured-macro ac:name="status">' +
'<ac:parameter ac:name="title">Proposed</ac:parameter>' +
'<ac:parameter ac:name="colour">Yellow</ac:parameter>' +
"</ac:structured-macro>";
html += "<p><strong>Date:</strong> " + date + "</p>";
html += "<h2>Context</h2><p>" + context + "</p>";
html += "<h2>Decision</h2><p>" + decision + "</p>";
html += "<h2>Consequences</h2><p>" + consequences + "</p>";
html += "<h2>Alternatives Considered</h2>" +
"<p><em>Document alternatives that were evaluated.</em></p>";
html += "<h2>Related Work Items</h2>" +
"<p><em>Link to relevant Azure DevOps work items.</em></p>";
return html;
}
Deployment Runbook Automation
Runbooks that drift from reality cause incidents. Generate them from your actual pipeline definitions and environment configurations:
async function generateDeploymentRunbook(pipelineId) {
var ado = createAzureDevOpsClient(
process.env.ADO_ORG_URL,
process.env.ADO_PROJECT,
process.env.ADO_PAT
);
var confluence = createConfluenceClient(
process.env.CONFLUENCE_BASE_URL,
process.env.CONFLUENCE_EMAIL,
process.env.CONFLUENCE_API_TOKEN
);
var pipelineResponse = await ado.getBuild(pipelineId);
var pipeline = pipelineResponse.data;
var html = '<ac:structured-macro ac:name="warning">' +
"<ac:rich-text-body><p>This runbook is auto-generated from the Azure DevOps " +
"pipeline configuration. Do not edit manually.</p></ac:rich-text-body>" +
"</ac:structured-macro>";
html += "<h2>Pipeline Details</h2>" +
"<p><strong>Name:</strong> " + pipeline.definition.name + "</p>" +
"<p><strong>Last Run:</strong> " + pipeline.finishTime + "</p>" +
"<p><strong>Status:</strong> " + pipeline.result + "</p>";
html += "<h2>Pre-Deployment Checklist</h2><ul>" +
"<li>Verify all tests pass on the build</li>" +
"<li>Confirm database migrations are reviewed</li>" +
"<li>Notify the on-call engineer</li>" +
"<li>Check monitoring dashboards for baseline metrics</li></ul>";
html += "<h2>Rollback Procedure</h2>" +
"<p>If the deployment fails or introduces regressions:</p><ol>" +
"<li>Navigate to the release pipeline in Azure DevOps</li>" +
"<li>Select the previous successful deployment</li>" +
"<li>Click <strong>Redeploy</strong></li>" +
"<li>Monitor application health for 15 minutes</li>" +
"<li>Create a bug work item for the failed deployment</li></ol>";
html += "<h2>Recent Deployment History</h2>" +
"<table><tr><th>Build</th><th>Date</th><th>Result</th></tr>";
// Fetch last 5 builds for this definition
var auth = Buffer.from(":" + process.env.ADO_PAT).toString("base64");
var historyResponse = await axios.get(
process.env.ADO_ORG_URL + "/" + process.env.ADO_PROJECT +
"/_apis/build/builds?definitions=" + pipeline.definition.id +
"&$top=5&api-version=7.1",
{ headers: { "Authorization": "Basic " + auth } }
);
historyResponse.data.value.forEach(function(b) {
html += "<tr><td>" + b.buildNumber + "</td><td>" +
b.finishTime + "</td><td>" + b.result + "</td></tr>";
});
html += "</table>";
var title = "Runbook: " + pipeline.definition.name;
await confluence.createPage(
process.env.CONFLUENCE_SPACE_KEY,
title,
html,
process.env.CONFLUENCE_RUNBOOK_PARENT_ID
);
return title;
}
Building a Doc Sync Service with Node.js
Now let us tie everything together into a single Express service that listens for webhooks from Azure DevOps and publishes documentation to Confluence:
var express = require("express");
var cron = require("node-cron");
var createConfluenceClient = require("./confluenceClient");
var createAzureDevOpsClient = require("./azureDevOpsClient");
var app = express();
app.use(express.json());
var PORT = process.env.PORT || 3000;
// Webhook endpoint for Azure DevOps build completion
app.post("/api/release-notes", function(req, res) {
var buildId = req.body.buildId;
if (!buildId) {
return res.status(400).json({ error: "buildId is required" });
}
publishReleaseNotes(buildId)
.then(function() {
res.json({ success: true, message: "Release notes published" });
})
.catch(function(err) {
console.error("Failed to publish release notes:", err.message);
res.status(500).json({ error: err.message });
});
});
// Webhook endpoint for sprint completion
app.post("/api/sprint-report", function(req, res) {
var iterationPath = req.body.iterationPath;
if (!iterationPath) {
return res.status(400).json({ error: "iterationPath is required" });
}
generateSprintReport(iterationPath)
.then(function(html) {
var confluence = createConfluenceClient(
process.env.CONFLUENCE_BASE_URL,
process.env.CONFLUENCE_EMAIL,
process.env.CONFLUENCE_API_TOKEN
);
var title = "Sprint Report - " + iterationPath.split("\\").pop();
return confluence.createPage(
process.env.CONFLUENCE_SPACE_KEY,
title,
html,
process.env.CONFLUENCE_SPRINT_PARENT_ID
);
})
.then(function() {
res.json({ success: true });
})
.catch(function(err) {
console.error("Failed to generate sprint report:", err.message);
res.status(500).json({ error: err.message });
});
});
// Sync epic documentation on demand
app.post("/api/sync-epic", function(req, res) {
var epicId = req.body.epicId;
syncEpicDocumentation(epicId)
.then(function() {
res.json({ success: true });
})
.catch(function(err) {
res.status(500).json({ error: err.message });
});
});
// Scheduled daily runbook refresh
cron.schedule("0 6 * * *", function() {
console.log("Running daily runbook refresh...");
var pipelineIds = (process.env.TRACKED_PIPELINES || "").split(",");
pipelineIds.forEach(function(id) {
if (id.trim()) {
generateDeploymentRunbook(id.trim()).catch(function(err) {
console.error("Runbook generation failed for pipeline " + id + ":", err.message);
});
}
});
});
app.listen(PORT, function() {
console.log("Doc sync service running on port " + PORT);
});
Confluence Macros for Azure DevOps Data
Confluence storage format supports structured macros that render as rich widgets. Here are the most useful ones for Azure DevOps data:
// Status badge macro
function statusMacro(label, color) {
// color: Green, Yellow, Red, Blue, Grey
return '<ac:structured-macro ac:name="status">' +
'<ac:parameter ac:name="title">' + label + '</ac:parameter>' +
'<ac:parameter ac:name="colour">' + color + '</ac:parameter>' +
'</ac:structured-macro>';
}
// Expandable section for long content
function expandMacro(title, content) {
return '<ac:structured-macro ac:name="expand">' +
'<ac:parameter ac:name="title">' + title + '</ac:parameter>' +
'<ac:rich-text-body>' + content + '</ac:rich-text-body>' +
'</ac:structured-macro>';
}
// Info panel
function infoPanelMacro(title, content) {
return '<ac:structured-macro ac:name="info">' +
'<ac:parameter ac:name="title">' + title + '</ac:parameter>' +
'<ac:rich-text-body>' + content + '</ac:rich-text-body>' +
'</ac:structured-macro>';
}
// Code block with syntax highlighting
function codeBlockMacro(language, code) {
return '<ac:structured-macro ac:name="code">' +
'<ac:parameter ac:name="language">' + language + '</ac:parameter>' +
'<ac:plain-text-body><![CDATA[' + code + ']]></ac:plain-text-body>' +
'</ac:structured-macro>';
}
These macros make your generated pages look native to Confluence rather than like dumped HTML.
Content Templates and Automation
Define reusable templates for different document types. Store them as JavaScript functions that accept data and return Confluence storage markup:
var templates = {
releaseNotes: function(data) {
var html = infoPanelMacro("Release Information",
"<p>Version: <strong>" + data.version + "</strong></p>" +
"<p>Date: " + data.date + "</p>" +
"<p>Build: " + data.buildNumber + "</p>"
);
html += "<h2>What Changed</h2>";
data.features.forEach(function(f) {
html += "<h3>" + statusMacro("Feature", "Green") + " " + f.title + "</h3>";
html += "<p>" + f.description + "</p>";
});
data.bugfixes.forEach(function(b) {
html += "<h3>" + statusMacro("Bug Fix", "Yellow") + " " + b.title + "</h3>";
html += "<p>" + b.description + "</p>";
});
if (data.breakingChanges && data.breakingChanges.length > 0) {
html += '<ac:structured-macro ac:name="warning">' +
"<ac:rich-text-body><h3>Breaking Changes</h3><ul>";
data.breakingChanges.forEach(function(c) {
html += "<li>" + c + "</li>";
});
html += "</ul></ac:rich-text-body></ac:structured-macro>";
}
return html;
},
apiDocumentation: function(data) {
var html = "<h1>" + data.serviceName + " API Documentation</h1>";
html += "<p>" + data.description + "</p>";
html += '<ac:structured-macro ac:name="toc" />';
data.endpoints.forEach(function(endpoint) {
html += "<h2>" + endpoint.method + " " + endpoint.path + "</h2>";
html += "<p>" + endpoint.description + "</p>";
if (endpoint.requestBody) {
html += "<h3>Request Body</h3>";
html += codeBlockMacro("json", JSON.stringify(endpoint.requestBody, null, 2));
}
if (endpoint.responseBody) {
html += "<h3>Response</h3>";
html += codeBlockMacro("json", JSON.stringify(endpoint.responseBody, null, 2));
}
});
return html;
}
};
Complete Working Example
Here is the full service that pulls work items from Azure DevOps on each release, generates formatted release notes with deployment history, and publishes them to Confluence. Save this as doc-sync-service.js:
var express = require("express");
var axios = require("axios");
var app = express();
app.use(express.json());
// --- Configuration ---
var CONFIG = {
ado: {
orgUrl: process.env.ADO_ORG_URL,
project: process.env.ADO_PROJECT,
pat: process.env.ADO_PAT
},
confluence: {
baseUrl: process.env.CONFLUENCE_BASE_URL,
email: process.env.CONFLUENCE_EMAIL,
apiToken: process.env.CONFLUENCE_API_TOKEN,
spaceKey: process.env.CONFLUENCE_SPACE_KEY,
parentPageId: process.env.CONFLUENCE_PARENT_PAGE_ID
}
};
// --- Azure DevOps helper ---
function adoRequest(path, method, body) {
var auth = Buffer.from(":" + CONFIG.ado.pat).toString("base64");
var url = CONFIG.ado.orgUrl + "/" + CONFIG.ado.project + "/_apis" + path;
var options = {
method: method || "GET",
url: url,
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json"
},
params: { "api-version": "7.1" }
};
if (body) options.data = body;
return axios(options);
}
// --- Confluence helper ---
function confluenceRequest(path, method, body) {
var auth = Buffer.from(
CONFIG.confluence.email + ":" + CONFIG.confluence.apiToken
).toString("base64");
var options = {
method: method || "GET",
url: CONFIG.confluence.baseUrl + "/wiki/rest/api" + path,
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json",
"Accept": "application/json"
}
};
if (body) options.data = body;
return axios(options);
}
// --- Fetch work items for a build ---
async function getWorkItemsForBuild(buildId) {
var response = await adoRequest(
"/build/builds/" + buildId + "/workitems", "GET"
);
var refs = response.data.value || [];
var workItems = [];
for (var i = 0; i < refs.length; i++) {
var wiResponse = await adoRequest(
"/wit/workitems/" + refs[i].id + "?$expand=relations", "GET"
);
workItems.push(wiResponse.data);
}
return workItems;
}
// --- Fetch deployment history ---
async function getDeploymentHistory(definitionId, count) {
var response = await adoRequest(
"/build/builds?definitions=" + definitionId + "&$top=" + (count || 10),
"GET"
);
return response.data.value || [];
}
// --- Build Confluence page content ---
function buildReleasePageContent(build, workItems, history) {
var date = new Date().toISOString().split("T")[0];
// Header panel
var html = '<ac:structured-macro ac:name="panel">' +
'<ac:parameter ac:name="title">Release Summary</ac:parameter>' +
'<ac:parameter ac:name="borderStyle">solid</ac:parameter>' +
"<ac:rich-text-body>" +
"<p><strong>Build Number:</strong> " + build.buildNumber + "</p>" +
"<p><strong>Source Branch:</strong> " + build.sourceBranch + "</p>" +
"<p><strong>Triggered By:</strong> " + build.requestedFor.displayName + "</p>" +
"<p><strong>Completed:</strong> " + date + "</p>" +
"<p><strong>Result:</strong> " +
'<ac:structured-macro ac:name="status">' +
'<ac:parameter ac:name="title">' + build.result + '</ac:parameter>' +
'<ac:parameter ac:name="colour">' +
(build.result === "succeeded" ? "Green" : "Red") +
"</ac:parameter></ac:structured-macro></p>" +
"</ac:rich-text-body></ac:structured-macro>";
// Table of contents
html += '<ac:structured-macro ac:name="toc"><ac:parameter ac:name="maxLevel">2' +
"</ac:parameter></ac:structured-macro>";
// Group work items by type
var groups = {};
workItems.forEach(function(wi) {
var type = wi.fields["System.WorkItemType"];
if (!groups[type]) groups[type] = [];
groups[type].push(wi);
});
// Render each group
var typeOrder = ["Epic", "Feature", "User Story", "Bug", "Task"];
typeOrder.forEach(function(type) {
if (!groups[type] || groups[type].length === 0) return;
html += "<h2>" + type + "s (" + groups[type].length + ")</h2>";
html += "<table><tr><th>ID</th><th>Title</th><th>State</th>" +
"<th>Assigned To</th><th>Story Points</th></tr>";
groups[type].forEach(function(wi) {
var assignedTo = wi.fields["System.AssignedTo"];
var name = assignedTo ? assignedTo.displayName : "Unassigned";
var points = wi.fields["Microsoft.VSTS.Scheduling.StoryPoints"] || "-";
var adoUrl = CONFIG.ado.orgUrl + "/" + CONFIG.ado.project +
"/_workitems/edit/" + wi.id;
html += "<tr><td><a href=\"" + adoUrl + "\">" + wi.id + "</a></td>" +
"<td>" + wi.fields["System.Title"] + "</td>" +
"<td>" + wi.fields["System.State"] + "</td>" +
"<td>" + name + "</td>" +
"<td>" + points + "</td></tr>";
});
html += "</table>";
});
// Deployment history
html += "<h2>Deployment History</h2>";
html += "<table><tr><th>Build</th><th>Branch</th><th>Date</th>" +
"<th>Result</th><th>Duration</th></tr>";
history.forEach(function(h) {
var start = new Date(h.startTime);
var finish = new Date(h.finishTime);
var duration = Math.round((finish - start) / 60000);
var color = h.result === "succeeded" ? "Green" : "Red";
html += "<tr><td>" + h.buildNumber + "</td>" +
"<td>" + h.sourceBranch.replace("refs/heads/", "") + "</td>" +
"<td>" + finish.toISOString().split("T")[0] + "</td>" +
"<td>" +
'<ac:structured-macro ac:name="status">' +
'<ac:parameter ac:name="title">' + h.result + '</ac:parameter>' +
'<ac:parameter ac:name="colour">' + color + '</ac:parameter>' +
"</ac:structured-macro></td>" +
"<td>" + duration + " min</td></tr>";
});
html += "</table>";
return html;
}
// --- Create or update the Confluence page ---
async function publishPage(title, content) {
// Check if page already exists
var cql = encodeURIComponent(
'space="' + CONFIG.confluence.spaceKey + '" AND title="' + title + '"'
);
var searchResponse = await confluenceRequest(
"/content?cql=" + cql, "GET"
);
var results = searchResponse.data.results || [];
if (results.length > 0) {
var existingId = results[0].id;
var detailResponse = await confluenceRequest(
"/content/" + existingId + "?expand=version", "GET"
);
var newVersion = detailResponse.data.version.number + 1;
await confluenceRequest("/content/" + existingId, "PUT", {
type: "page",
title: title,
space: { key: CONFIG.confluence.spaceKey },
body: {
storage: { value: content, representation: "storage" }
},
version: { number: newVersion }
});
return { action: "updated", pageId: existingId };
}
var createResponse = await confluenceRequest("/content", "POST", {
type: "page",
title: title,
space: { key: CONFIG.confluence.spaceKey },
ancestors: [{ id: CONFIG.confluence.parentPageId }],
body: {
storage: { value: content, representation: "storage" }
}
});
return { action: "created", pageId: createResponse.data.id };
}
// --- Main endpoint ---
app.post("/api/publish-release", async function(req, res) {
try {
var buildId = req.body.buildId;
if (!buildId) {
return res.status(400).json({ error: "buildId is required" });
}
console.log("Fetching build " + buildId + "...");
var buildResponse = await adoRequest("/build/builds/" + buildId, "GET");
var build = buildResponse.data;
console.log("Fetching work items...");
var workItems = await getWorkItemsForBuild(buildId);
console.log("Fetching deployment history...");
var history = await getDeploymentHistory(build.definition.id, 10);
console.log("Building page content...");
var content = buildReleasePageContent(build, workItems, history);
var title = "Release " + build.buildNumber + " - " +
new Date().toISOString().split("T")[0];
console.log("Publishing to Confluence...");
var result = await publishPage(title, content);
console.log("Page " + result.action + ": " + title);
res.json({
success: true,
action: result.action,
pageId: result.pageId,
title: title,
workItemCount: workItems.length
});
} catch (err) {
console.error("Publish failed:", err.response ? err.response.data : err.message);
res.status(500).json({
error: "Failed to publish release notes",
detail: err.response ? err.response.data : err.message
});
}
});
// Health check
app.get("/health", function(req, res) {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
console.log("Doc sync service listening on port " + PORT);
});
Test it locally:
export ADO_ORG_URL="https://dev.azure.com/your-org"
export ADO_PROJECT="your-project"
export ADO_PAT="your-pat-here"
export CONFLUENCE_BASE_URL="https://your-domain.atlassian.net"
export CONFLUENCE_EMAIL="[email protected]"
export CONFLUENCE_API_TOKEN="your-confluence-token"
export CONFLUENCE_SPACE_KEY="ENG"
export CONFLUENCE_PARENT_PAGE_ID="12345678"
node doc-sync-service.js
# In another terminal
curl -X POST http://localhost:3000/api/publish-release \
-H "Content-Type: application/json" \
-d '{"buildId": "54321"}'
Common Issues and Troubleshooting
1. Confluence returns 400 with "Could not find valid content body"
This happens when your storage format contains invalid XHTML. Every tag must be properly closed, attributes must be quoted, and ampersands must be encoded as &. Use a validation step before publishing:
function sanitizeForConfluence(html) {
return html
.replace(/&(?!amp;|lt;|gt;|quot;|apos;)/g, "&")
.replace(/<br>/g, "<br />")
.replace(/<hr>/g, "<hr />");
}
2. Azure DevOps PAT returns 401 after it was working
PATs expire. The default expiration is 30 days unless you set a custom expiration. Implement a health check that verifies the token on startup and logs clearly when authentication fails. Better yet, use a service principal with OAuth instead of a PAT for production services.
3. Confluence page title conflicts
Page titles must be unique within a space. If you try to create a page with a title that already exists, you get a 409 Conflict. Always search for existing pages first and update them instead of creating duplicates. The findPage method in the client above handles this.
4. Rate limiting on both APIs
Confluence Cloud enforces rate limits (roughly 100 requests per minute per user). Azure DevOps has similar limits. When processing large numbers of work items, batch your requests and add delays:
function delay(ms) {
return new Promise(function(resolve) {
setTimeout(resolve, ms);
});
}
async function batchFetchWorkItems(ids, batchSize) {
var results = [];
for (var i = 0; i < ids.length; i += batchSize) {
var batch = ids.slice(i, i + batchSize);
var promises = batch.map(function(id) {
return adoRequest("/wit/workitems/" + id, "GET");
});
var responses = await Promise.all(promises);
responses.forEach(function(r) { results.push(r.data); });
if (i + batchSize < ids.length) {
await delay(1000);
}
}
return results;
}
5. Large pages cause slow rendering in Confluence
If your release spans hundreds of work items, the generated page becomes unwieldy. Use the expand macro to collapse sections by default, and consider splitting large releases into child pages under a parent release page.
6. Unicode and special characters in work item titles
Work item titles can contain characters that break XHTML storage format. Escape angle brackets, ampersands, and quotes in all user-generated content before embedding it in the page body.
Best Practices
Idempotent publishing: Always check if a page exists before creating it. Update existing pages rather than creating duplicates. Use deterministic titles so the same build always maps to the same page.
Template everything: Define Confluence page templates in code, not as ad-hoc string concatenation scattered across functions. Store templates in a dedicated module with clear interfaces.
Bidirectional linking: When you create a Confluence page from a work item, link the page URL back to the work item. Engineers should be able to navigate in both directions without searching.
Include metadata: Every auto-generated page should state that it was auto-generated, when, from what source, and include a link back to the pipeline run. This prevents confusion when someone tries to edit an auto-generated page.
Version pages, do not duplicate: Update the same page with a new version rather than creating a new page for every build. Confluence version history gives you the full audit trail for free.
Use Confluence macros over raw HTML: Status badges, panels, expand sections, and code blocks make your pages look professional and integrate with Confluence features like search and filtering.
Secure your tokens: Never log API tokens. Use environment variables or a secrets manager. Rotate Confluence API tokens and Azure DevOps PATs on a regular schedule. Consider using OAuth 2.0 with service principals for production deployments.
Test with a sandbox space: Create a dedicated Confluence space for testing your integration. Publishing broken pages to your production documentation space will erode trust in the automation quickly.
Monitor failures: Wrap every publish call in error handling and send alerts when documentation fails to generate. Stale documentation is worse than no documentation because people trust it.
Keep page hierarchies clean: Organize generated pages under dedicated parent pages (Release Notes, Sprint Reports, Runbooks). Do not dump everything at the root of a space.