/home/techb158/cosmic-risk.abdallabala.com/src/integrations/pm-clients
Edit: /home/techb158/cosmic-risk.abdallabala.com/src/integrations/pm-clients/trello-client.js (8790B)
const { fetchJson, encodeQuery, buildRiskDescription } = require("./http-client");
const DIMENSION_COLORS = {
Organizational: "yellow", Technical: "purple", Human: "blue",
Governance: "red", Legal: "green", Ethical: "orange", Operational: "sky"
};
const PHASE_COLORS = {
Design: "pink", Development: "lime", Testing: "black", Deployment: "sky"
};
const ALLOWED_COLORS = new Set(["yellow","purple","blue","red","green","orange","black","sky","pink","lime"]);
function unusedColor(used) {
for (const c of ALLOWED_COLORS) {
if (!used.has(c)) return c;
}
return "green";
}
class TrelloClient {
constructor(options = {}) {
this.apiKey = options.apiKey;
this.token = options.token;
this.listId = options.listId;
this.fetchImpl = options.fetchImpl;
this.statusListMap = options.statusListMap || {};
this._labelCache = null;
if (!this.apiKey || !this.token) {
throw new Error("Trello live sync requires apiKey and token credentials.");
}
}
authQuery(extra = {}) {
return encodeQuery(Object.assign({}, extra, { key: this.apiKey, token: this.token }));
}
async testConnection() {
const member = await fetchJson(`https://api.trello.com/1/members/me?${this.authQuery({ fields: "id,username,fullName" })}`, {}, this.fetchImpl);
return { ok: true, provider: "TRELLO", account: member.fullName || member.username || member.id };
}
async _boardIdForList(listId) {
if (!listId || !/^[a-f0-9]{24}$/i.test(listId)) {
return null;
}
try {
const list = await fetchJson(`https://api.trello.com/1/lists/${listId}?${this.authQuery({ fields: "idBoard" })}`, {}, this.fetchImpl);
return list.idBoard;
} catch (err) {
return null;
}
}
async _existingLabels(boardId) {
return fetchJson(`https://api.trello.com/1/boards/${boardId}/labels?${this.authQuery({ fields: "id,name,color" })}`, {}, this.fetchImpl);
}
async _createLabel(boardId, name, color) {
return fetchJson(`https://api.trello.com/1/boards/${boardId}/labels?${this.authQuery()}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color })
}, this.fetchImpl);
}
async ensureLabels(boardId) {
if (this._labelCache && this._labelCache.boardId === boardId) return this._labelCache;
const existing = await this._existingLabels(boardId);
const byName = {};
for (const label of existing) byName[label.name] = label;
const desired = [
...Object.keys(DIMENSION_COLORS).map(d => ({ name: `COSMIC:${d}`, color: DIMENSION_COLORS[d] })),
...Object.keys(PHASE_COLORS).map(p => ({ name: `COSMIC:Phase:${p}`, color: PHASE_COLORS[p] }))
];
const labelMap = {};
const usedColors = new Set(existing.map(l => l.color).filter(Boolean));
for (const item of desired) {
if (byName[item.name]) {
labelMap[item.name] = byName[item.name].id;
} else {
const color = ALLOWED_COLORS.has(item.color) && !usedColors.has(item.color)
? item.color : unusedColor(usedColors);
const created = await this._createLabel(boardId, item.name, color).catch(() => null);
if (created) {
usedColors.add(created.color);
labelMap[item.name] = created.id;
}
}
}
this._labelCache = { boardId, labelMap };
return this._labelCache;
}
async _resolveListId(risk, integration) {
const override = integration.liveConfig?.statusListMap;
const map = override || this.statusListMap;
const mapped = map[risk.status];
if (mapped) return mapped;
const fallback = integration.externalProjectKey || this.listId;
if (!fallback) return null;
return fallback;
}
async _resolveLabelIds(risk, integration) {
try {
const listId = await this._resolveListId(risk, integration);
if (!listId) return [];
const boardId = await this._boardIdForList(listId);
if (!boardId) return [];
const cache = await this.ensureLabels(boardId);
const ids = [];
const dimName = `COSMIC:${risk.dimension}`;
const phaseName = `COSMIC:Phase:${risk.lifecyclePhase}`;
if (cache.labelMap[dimName]) ids.push(cache.labelMap[dimName]);
if (cache.labelMap[phaseName]) ids.push(cache.labelMap[phaseName]);
return ids;
} catch (err) {
return [];
}
}
async createRiskWorkItem(risk, mitigations = [], integration = {}) {
const listId = await this._resolveListId(risk, integration);
if (!listId) {
throw new Error("Trello live sync requires a list ID. Configure status→list mapping or set externalProjectKey.");
}
const idLabels = await this._resolveLabelIds(risk, integration);
if (!/^[a-f0-9]{24}$/i.test(listId)) {
throw new Error(
`Invalid Trello list ID "${listId}" (status="${risk.status}"). Expected a 24-character hex ID. ` +
`Use "Discover Boards" to find valid list IDs, then set externalProjectKey or configure status→list mapping.`
);
}
const card = await fetchJson(`https://api.trello.com/1/cards?${this.authQuery()}`, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({
idList: listId,
name: `[${risk.status}] ${risk.title}`,
desc: buildRiskDescription(risk, mitigations),
idLabels: idLabels.length ? idLabels : undefined
})
}, this.fetchImpl);
return {
externalId: card.id,
externalKey: card.shortLink || card.id,
externalUrl: card.shortUrl || card.url || `https://trello.com/c/${card.shortLink || card.id}`,
externalStatus: risk.status
};
}
async updateRiskWorkItem(mapping, risk, mitigations = []) {
const listId = await this._resolveListId(risk, { liveConfig: { statusListMap: this.statusListMap } });
const body = {
name: `[${risk.status}] ${risk.title}`,
desc: buildRiskDescription(risk, mitigations)
};
if (listId) {
if (!/^[a-f0-9]{24}$/i.test(listId)) {
throw new Error(
`Invalid Trello list ID "${listId}" (status="${risk.status}"). Expected a 24-character hex ID. ` +
`Use "Discover Boards" to find valid list IDs.`
);
}
body.idList = listId;
}
const card = await fetchJson(`https://api.trello.com/1/cards/${encodeURIComponent(mapping.externalItemId)}?${this.authQuery()}`, {
method: "PUT",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(body)
}, this.fetchImpl);
return {
externalId: card.id || mapping.externalItemId,
externalKey: card.shortLink || mapping.externalItemKey || mapping.externalItemId,
externalUrl: card.shortUrl || card.url || mapping.externalUrl,
externalStatus: risk.status
};
}
async fetchWorkItems(options = {}) {
const listId = options.listId || this.listId;
if (!listId) {
throw new Error("Trello fetchWorkItems requires a listId.");
}
if (!/^[a-f0-9]{24}$/i.test(listId)) {
throw new Error(`Invalid Trello list ID "${listId}". Expected a 24-character hex ID.`);
}
const cards = await fetchJson(`https://api.trello.com/1/lists/${listId}/cards?${this.authQuery({ fields: "id,name,desc,idList,due,idMembers,labels,shortLink,shortUrl" })}`, {}, this.fetchImpl);
return (cards || []).map(card => ({
id: card.id,
title: card.name,
description: card.desc || "",
listId: card.idList,
dueDate: card.due || null,
labels: (card.labels || []).map(l => ({ id: l.id, name: l.name, color: l.color })),
memberIds: card.idMembers || [],
externalUrl: card.shortUrl || `https://trello.com/c/${card.shortLink || card.id}`,
externalKey: card.shortLink || card.id,
raw: card
}));
}
async discoverResources() {
const member = await fetchJson(`https://api.trello.com/1/members/me?${this.authQuery({ fields: "id,username,fullName" })}`, {}, this.fetchImpl);
const boards = await fetchJson(`https://api.trello.com/1/members/${member.id}/boards?${this.authQuery({ fields: "id,name,closed", filter: "open" })}`, {}, this.fetchImpl);
const result = [];
for (const board of boards) {
if (board.closed) continue;
const lists = await fetchJson(`https://api.trello.com/1/boards/${board.id}/lists?${this.authQuery({ fields: "id,name,closed" })}`, {}, this.fetchImpl);
result.push({
type: "board",
id: board.id,
name: board.name,
lists: lists.filter(l => !l.closed).map(l => ({ type: "list", id: l.id, name: l.name, parentId: board.id, parentName: board.name }))
});
}
return result;
}
}
module.exports = { TrelloClient };