@grainulation/silo 1.0.0 → 1.0.1
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/CODE_OF_CONDUCT.md +25 -0
- package/CONTRIBUTING.md +103 -0
- package/README.md +67 -59
- package/bin/silo.js +212 -86
- package/lib/analytics.js +26 -11
- package/lib/confluence.js +343 -0
- package/lib/graph.js +414 -0
- package/lib/import-export.js +29 -24
- package/lib/index.js +15 -9
- package/lib/packs.js +60 -36
- package/lib/search.js +24 -16
- package/lib/serve-mcp.js +391 -95
- package/lib/server.js +205 -110
- package/lib/store.js +34 -18
- package/lib/templates.js +28 -17
- package/package.json +7 -3
- package/packs/adr.json +219 -0
- package/packs/api-design.json +67 -14
- package/packs/architecture-decision.json +152 -0
- package/packs/architecture.json +45 -9
- package/packs/ci-cd.json +51 -11
- package/packs/compliance.json +70 -14
- package/packs/data-engineering.json +57 -12
- package/packs/frontend.json +56 -12
- package/packs/hackathon-best-ai.json +179 -0
- package/packs/hackathon-business-impact.json +180 -0
- package/packs/hackathon-innovation.json +210 -0
- package/packs/hackathon-most-innovative.json +179 -0
- package/packs/hackathon-most-rigorous.json +179 -0
- package/packs/hackathon-sprint-boost.json +173 -0
- package/packs/incident-postmortem.json +219 -0
- package/packs/migration.json +45 -9
- package/packs/observability.json +57 -12
- package/packs/security.json +61 -13
- package/packs/team-process.json +64 -13
- package/packs/testing.json +20 -4
- package/packs/vendor-eval.json +219 -0
- package/packs/vendor-evaluation.json +148 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* confluence.js — Confluence backend adapter for silo
|
|
3
|
+
*
|
|
4
|
+
* Publishes wheat compilation briefs and claim collections to Confluence pages,
|
|
5
|
+
* and pulls existing Confluence pages into silo claims. Uses Confluence REST API v2
|
|
6
|
+
* via node:https (zero npm deps).
|
|
7
|
+
*
|
|
8
|
+
* Configuration:
|
|
9
|
+
* CONFLUENCE_BASE_URL — e.g. https://myorg.atlassian.net/wiki
|
|
10
|
+
* CONFLUENCE_TOKEN — API token (Atlassian account settings)
|
|
11
|
+
* CONFLUENCE_EMAIL — User email for Basic auth
|
|
12
|
+
* CONFLUENCE_SPACE_KEY — Target space key (e.g. "ENG")
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const https = require("node:https");
|
|
16
|
+
const http = require("node:http");
|
|
17
|
+
const { URL } = require("node:url");
|
|
18
|
+
|
|
19
|
+
class Confluence {
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {string} opts.baseUrl — Confluence base URL
|
|
23
|
+
* @param {string} opts.token — API token
|
|
24
|
+
* @param {string} opts.email — User email
|
|
25
|
+
* @param {string} opts.spaceKey — Default space key
|
|
26
|
+
*/
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
this.baseUrl = (
|
|
29
|
+
opts.baseUrl ||
|
|
30
|
+
process.env.CONFLUENCE_BASE_URL ||
|
|
31
|
+
""
|
|
32
|
+
).replace(/\/+$/, "");
|
|
33
|
+
this.token = opts.token || process.env.CONFLUENCE_TOKEN || "";
|
|
34
|
+
this.email = opts.email || process.env.CONFLUENCE_EMAIL || "";
|
|
35
|
+
this.spaceKey = opts.spaceKey || process.env.CONFLUENCE_SPACE_KEY || "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Check if the adapter is configured with required credentials. */
|
|
39
|
+
isConfigured() {
|
|
40
|
+
return Boolean(this.baseUrl && this.token && this.email);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Publish a set of claims as a Confluence page.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} title - Page title
|
|
47
|
+
* @param {object[]} claims - Array of wheat-canonical claims
|
|
48
|
+
* @param {object} opts
|
|
49
|
+
* @param {string} opts.spaceKey - Override default space key
|
|
50
|
+
* @param {string} opts.parentId - Parent page ID (optional)
|
|
51
|
+
* @param {string} opts.pageId - Existing page ID to update (optional)
|
|
52
|
+
* @returns {Promise<{id: string, url: string, title: string, version: number}>}
|
|
53
|
+
*/
|
|
54
|
+
async publish(title, claims, opts = {}) {
|
|
55
|
+
this._requireConfig();
|
|
56
|
+
const spaceKey = opts.spaceKey || this.spaceKey;
|
|
57
|
+
if (!spaceKey)
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Confluence space key required (opts.spaceKey or CONFLUENCE_SPACE_KEY)",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const body = this._claimsToStorageFormat(title, claims);
|
|
63
|
+
|
|
64
|
+
if (opts.pageId) {
|
|
65
|
+
return this._updatePage(opts.pageId, title, body);
|
|
66
|
+
}
|
|
67
|
+
return this._createPage(spaceKey, title, body, opts.parentId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pull claims from a Confluence page by ID or title search.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} pageIdOrTitle - Page ID (numeric) or title to search
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {string} opts.spaceKey - Space to search in
|
|
76
|
+
* @returns {Promise<{title: string, claims: object[]}>}
|
|
77
|
+
*/
|
|
78
|
+
async pull(pageIdOrTitle, opts = {}) {
|
|
79
|
+
this._requireConfig();
|
|
80
|
+
|
|
81
|
+
let pageId = pageIdOrTitle;
|
|
82
|
+
if (!/^\d+$/.test(pageIdOrTitle)) {
|
|
83
|
+
pageId = await this._findPageByTitle(
|
|
84
|
+
pageIdOrTitle,
|
|
85
|
+
opts.spaceKey || this.spaceKey,
|
|
86
|
+
);
|
|
87
|
+
if (!pageId)
|
|
88
|
+
throw new Error(`Confluence page not found: "${pageIdOrTitle}"`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const page = await this._getPage(pageId);
|
|
92
|
+
const claims = this._parseStorageFormat(page.body?.storage?.value || "");
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
title: page.title,
|
|
96
|
+
pageId: page.id,
|
|
97
|
+
claims,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List pages in a space that look like silo claim collections.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} opts
|
|
105
|
+
* @param {string} opts.spaceKey - Space key
|
|
106
|
+
* @param {number} opts.limit - Max results
|
|
107
|
+
* @returns {Promise<{pages: object[]}>}
|
|
108
|
+
*/
|
|
109
|
+
async listPages(opts = {}) {
|
|
110
|
+
this._requireConfig();
|
|
111
|
+
const spaceKey = opts.spaceKey || this.spaceKey;
|
|
112
|
+
if (!spaceKey) throw new Error("Space key required");
|
|
113
|
+
|
|
114
|
+
const cql = encodeURIComponent(
|
|
115
|
+
`space="${spaceKey}" AND label="silo-claims" ORDER BY lastModified DESC`,
|
|
116
|
+
);
|
|
117
|
+
const data = await this._request(
|
|
118
|
+
"GET",
|
|
119
|
+
`/rest/api/content/search?cql=${cql}&limit=${opts.limit || 25}`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
pages: (data.results || []).map((p) => ({
|
|
124
|
+
id: p.id,
|
|
125
|
+
title: p.title,
|
|
126
|
+
url: `${this.baseUrl}${p._links?.webui || ""}`,
|
|
127
|
+
lastModified: p.version?.when,
|
|
128
|
+
})),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── HTML generation ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
_claimsToStorageFormat(title, claims) {
|
|
135
|
+
const rows = claims.map((c) => {
|
|
136
|
+
const evidence = c.evidence || c.tier || "stated";
|
|
137
|
+
const source =
|
|
138
|
+
typeof c.source === "object"
|
|
139
|
+
? c.source.artifact || c.source.origin || ""
|
|
140
|
+
: c.source || "";
|
|
141
|
+
return `<tr>
|
|
142
|
+
<td>${_esc(c.id || "")}</td>
|
|
143
|
+
<td>${_esc(c.type || "")}</td>
|
|
144
|
+
<td>${_esc(c.topic || "")}</td>
|
|
145
|
+
<td>${_esc(c.content || c.text || "")}</td>
|
|
146
|
+
<td>${_esc(evidence)}</td>
|
|
147
|
+
<td>${_esc(source)}</td>
|
|
148
|
+
<td>${_esc((c.tags || []).join(", "))}</td>
|
|
149
|
+
</tr>`;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return `<h2>Claims (${claims.length})</h2>
|
|
153
|
+
<table>
|
|
154
|
+
<thead><tr>
|
|
155
|
+
<th>ID</th><th>Type</th><th>Topic</th><th>Content</th><th>Evidence</th><th>Source</th><th>Tags</th>
|
|
156
|
+
</tr></thead>
|
|
157
|
+
<tbody>${rows.join("\n")}</tbody>
|
|
158
|
+
</table>
|
|
159
|
+
<p><em>Published from silo on ${new Date().toISOString()}</em></p>
|
|
160
|
+
<!-- silo-meta: ${JSON.stringify({ claimCount: claims.length, exportedAt: new Date().toISOString() })} -->`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_parseStorageFormat(html) {
|
|
164
|
+
const claims = [];
|
|
165
|
+
// Extract table rows: each <tr> in tbody represents a claim
|
|
166
|
+
const rowRe =
|
|
167
|
+
/<tr>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<td>(.*?)<\/td>\s*<\/tr>/gs;
|
|
168
|
+
let match;
|
|
169
|
+
while ((match = rowRe.exec(html)) !== null) {
|
|
170
|
+
const [, id, type, topic, content, evidence, source, tagsStr] = match;
|
|
171
|
+
claims.push({
|
|
172
|
+
id: _unesc(id),
|
|
173
|
+
type: _unesc(type) || "factual",
|
|
174
|
+
topic: _unesc(topic),
|
|
175
|
+
content: _unesc(content),
|
|
176
|
+
evidence: _unesc(evidence) || "stated",
|
|
177
|
+
status: "active",
|
|
178
|
+
phase_added: "import",
|
|
179
|
+
timestamp: new Date().toISOString(),
|
|
180
|
+
source: {
|
|
181
|
+
origin: "confluence",
|
|
182
|
+
artifact: _unesc(source) || null,
|
|
183
|
+
connector: null,
|
|
184
|
+
},
|
|
185
|
+
conflicts_with: [],
|
|
186
|
+
resolved_by: null,
|
|
187
|
+
tags: tagsStr
|
|
188
|
+
? _unesc(tagsStr)
|
|
189
|
+
.split(",")
|
|
190
|
+
.map((t) => t.trim())
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
: [],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return claims;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── HTTP helpers ─────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
_requireConfig() {
|
|
201
|
+
if (!this.isConfigured()) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
"Confluence not configured. Set CONFLUENCE_BASE_URL, CONFLUENCE_TOKEN, and CONFLUENCE_EMAIL.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async _createPage(spaceKey, title, body, parentId) {
|
|
209
|
+
const payload = {
|
|
210
|
+
type: "page",
|
|
211
|
+
title,
|
|
212
|
+
space: { key: spaceKey },
|
|
213
|
+
body: { storage: { value: body, representation: "storage" } },
|
|
214
|
+
metadata: { labels: [{ name: "silo-claims" }] },
|
|
215
|
+
};
|
|
216
|
+
if (parentId) {
|
|
217
|
+
payload.ancestors = [{ id: parentId }];
|
|
218
|
+
}
|
|
219
|
+
const data = await this._request("POST", "/rest/api/content", payload);
|
|
220
|
+
return {
|
|
221
|
+
id: data.id,
|
|
222
|
+
title: data.title,
|
|
223
|
+
url: `${this.baseUrl}${data._links?.webui || ""}`,
|
|
224
|
+
version: data.version?.number || 1,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async _updatePage(pageId, title, body) {
|
|
229
|
+
const existing = await this._request(
|
|
230
|
+
"GET",
|
|
231
|
+
`/rest/api/content/${pageId}?expand=version`,
|
|
232
|
+
);
|
|
233
|
+
const payload = {
|
|
234
|
+
type: "page",
|
|
235
|
+
title,
|
|
236
|
+
body: { storage: { value: body, representation: "storage" } },
|
|
237
|
+
version: { number: (existing.version?.number || 0) + 1 },
|
|
238
|
+
};
|
|
239
|
+
const data = await this._request(
|
|
240
|
+
"PUT",
|
|
241
|
+
`/rest/api/content/${pageId}`,
|
|
242
|
+
payload,
|
|
243
|
+
);
|
|
244
|
+
return {
|
|
245
|
+
id: data.id,
|
|
246
|
+
title: data.title,
|
|
247
|
+
url: `${this.baseUrl}${data._links?.webui || ""}`,
|
|
248
|
+
version: data.version?.number,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async _getPage(pageId) {
|
|
253
|
+
return this._request(
|
|
254
|
+
"GET",
|
|
255
|
+
`/rest/api/content/${pageId}?expand=body.storage,version`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async _findPageByTitle(title, spaceKey) {
|
|
260
|
+
const cql = encodeURIComponent(`space="${spaceKey}" AND title="${title}"`);
|
|
261
|
+
const data = await this._request(
|
|
262
|
+
"GET",
|
|
263
|
+
`/rest/api/content/search?cql=${cql}&limit=1`,
|
|
264
|
+
);
|
|
265
|
+
return data.results?.[0]?.id || null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_request(method, apiPath, body) {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const url = new URL(apiPath, this.baseUrl);
|
|
271
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
272
|
+
const auth = Buffer.from(`${this.email}:${this.token}`).toString(
|
|
273
|
+
"base64",
|
|
274
|
+
);
|
|
275
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
276
|
+
|
|
277
|
+
const req = mod.request(
|
|
278
|
+
url,
|
|
279
|
+
{
|
|
280
|
+
method,
|
|
281
|
+
headers: {
|
|
282
|
+
Authorization: `Basic ${auth}`,
|
|
283
|
+
Accept: "application/json",
|
|
284
|
+
...(payload
|
|
285
|
+
? {
|
|
286
|
+
"Content-Type": "application/json",
|
|
287
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
288
|
+
}
|
|
289
|
+
: {}),
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
(res) => {
|
|
293
|
+
let data = "";
|
|
294
|
+
res.on("data", (chunk) => {
|
|
295
|
+
data += chunk;
|
|
296
|
+
});
|
|
297
|
+
res.on("end", () => {
|
|
298
|
+
if (res.statusCode >= 400) {
|
|
299
|
+
reject(
|
|
300
|
+
new Error(
|
|
301
|
+
`Confluence API ${res.statusCode}: ${data.slice(0, 200)}`,
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
resolve(JSON.parse(data));
|
|
308
|
+
} catch {
|
|
309
|
+
resolve({ raw: data });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
req.on("error", reject);
|
|
316
|
+
req.setTimeout(30000, () => {
|
|
317
|
+
req.destroy(new Error("Confluence request timeout"));
|
|
318
|
+
});
|
|
319
|
+
if (payload) req.write(payload);
|
|
320
|
+
req.end();
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── HTML escaping helpers ────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
function _esc(str) {
|
|
328
|
+
return String(str)
|
|
329
|
+
.replace(/&/g, "&")
|
|
330
|
+
.replace(/</g, "<")
|
|
331
|
+
.replace(/>/g, ">")
|
|
332
|
+
.replace(/"/g, """);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function _unesc(str) {
|
|
336
|
+
return String(str)
|
|
337
|
+
.replace(/&/g, "&")
|
|
338
|
+
.replace(/</g, "<")
|
|
339
|
+
.replace(/>/g, ">")
|
|
340
|
+
.replace(/"/g, '"');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = { Confluence };
|