@bitpeakdev/suporta-mcp-server
Version:
MCP server for Suporta Helpdesk — manage tickets from Claude Code
263 lines (223 loc) • 8.81 kB
JavaScript
/**
* Pure set arithmetic for the two ticket link lists: pull requests and
* repository branches.
*
* Both live behind a PUT that takes the COMPLETE desired set — omission is
* deletion (`TicketPullRequestService::sync`, `TicketRepositoryBranchService::sync`).
* That contract suits the web sidebar, which always holds the whole list, and
* is a trap for a tool caller who only knows about the one link they want to
* add: sending `[{url}]` would silently unlink everything already there.
*
* So every tool in this server reads first, merges here, and writes the result.
* The merge is kept query-free precisely so the "does adding one link preserve
* the others" question is answered by a test rather than by a live ticket.
*/
const text = (value) => `${value ?? ""}`.trim();
/* ── Pull requests ──────────────────────────────────────────────────────── */
/**
* Why this URL cannot be linked, or null when it is fine.
*
* The server validates with Laravel's `url` rule and answers 422. Checking the
* two failures worth naming here turns that into a message that says what to
* pass instead, without a round trip.
*/
export function pullRequestUrlRefusal(url) {
const value = text(url);
if (value === "") {
return "A pull request URL is required.";
}
if (!/^https?:\/\/\S+$/i.test(value)) {
return `"${value}" is not a URL. Pass the full link, e.g. https://bitbucket.org/workspace/repo/pull-requests/42`;
}
return null;
}
/**
* The rows a GET returns, reduced to what a PUT accepts.
*
* The read carries `id`, `added_by`, `created_at` and `state`; sending those
* back is harmless but noisy, and `description` must survive the round trip or
* an unrelated add would blank it.
*/
export function pullRequestsForWrite(rows) {
return (Array.isArray(rows) ? rows : [])
.filter((row) => text(row?.url) !== "")
.map((row) => ({ url: text(row.url), description: text(row.description) }));
}
/**
* Add or update one pull request, keeping every other link in place.
*
* The URL is the identity (the table is unique on ticket + url), so linking a
* URL that is already there is an edit of its description rather than a
* duplicate row — matching what the server's own diff does.
*/
export function mergePullRequest(existing, pullRequest, { replaceAll = false } = {}) {
const url = text(pullRequest?.url);
const description = text(pullRequest?.description);
const before = pullRequestsForWrite(existing);
if (replaceAll) {
return {
desired: [{ url, description }],
wasPresent: before.some((row) => row.url === url),
removedCount: before.filter((row) => row.url !== url).length,
};
}
const wasPresent = before.some((row) => row.url === url);
return {
desired: wasPresent
? before.map((row) => (row.url === url ? { url, description } : row))
: [...before, { url, description }],
wasPresent,
removedCount: 0,
};
}
/**
* Drop one pull request, or all of them.
*
* `all` exists because the alternative — asking a caller to enumerate what it
* wants to keep — is the very footgun this module is here to close.
*/
export function removePullRequest(existing, { url, all = false } = {}) {
const before = pullRequestsForWrite(existing);
if (all) {
return { desired: [], removed: before.map((row) => row.url) };
}
const target = text(url);
const desired = before.filter((row) => row.url !== target);
return {
desired,
removed: desired.length === before.length ? [] : [target],
};
}
/* ── Repository branches ────────────────────────────────────────────────── */
const repositoryKey = (row) =>
`${text(row?.workspace_slug)}/${text(row?.repository_slug)}`.toLowerCase();
/** The rows a GET returns, reduced to what a PUT accepts. */
export function branchesForWrite(rows) {
return (Array.isArray(rows) ? rows : [])
.filter(
(row) =>
text(row?.workspace_slug) !== "" &&
text(row?.repository_slug) !== "" &&
text(row?.branch_name) !== ""
)
.map((row) => ({
workspace_slug: text(row.workspace_slug),
repository_slug: text(row.repository_slug),
branch_name: text(row.branch_name),
}));
}
/**
* Which repository a caller means, resolved against what the ticket may use.
*
* The server refuses a repository the project has not mapped (422
* `unknown_repository`), and it compares slugs exactly — so this returns the
* MAPPED spelling rather than whatever the caller typed, and a case difference
* stops being a failed write. Repositories that only appear on existing
* branches count too: a mapping can be deleted and recreated, and a branch left
* behind by one must stay editable (`selectableKeys`).
*
* Omitting the repository is allowed when there is exactly one candidate, which
* is the common shape — one project, one repo.
*/
export function resolveRepository(repositories, existingBranches, hint = {}) {
const candidates = [];
const seen = new Set();
for (const row of [
...(Array.isArray(repositories) ? repositories : []),
...branchesForWrite(existingBranches),
]) {
const workspace = text(row?.workspace_slug);
const repository = text(row?.repository_slug);
if (workspace === "" || repository === "") continue;
const key = repositoryKey(row);
if (seen.has(key)) continue;
seen.add(key);
candidates.push({ workspace_slug: workspace, repository_slug: repository });
}
const names = candidates.map((c) => `${c.workspace_slug}/${c.repository_slug}`);
if (candidates.length === 0) {
return {
error:
"This ticket's project has no Bitbucket repositories mapped, so a branch cannot be linked. " +
"Map one under the project's settings first.",
};
}
const workspace = text(hint.workspace_slug).toLowerCase();
const repository = text(hint.repository_slug).toLowerCase();
if (repository === "") {
if (candidates.length === 1) {
return candidates[0];
}
return {
error: `This project maps ${candidates.length} repositories, so repository_slug is required. Available: ${names.join(", ")}`,
};
}
const matches = candidates.filter(
(c) =>
c.repository_slug.toLowerCase() === repository &&
(workspace === "" || c.workspace_slug.toLowerCase() === workspace)
);
if (matches.length === 0) {
return {
error: `"${text(hint.repository_slug)}" is not a repository this ticket can use. Available: ${names.join(", ")}`,
};
}
if (matches.length > 1) {
return {
error: `"${text(hint.repository_slug)}" is mapped in more than one workspace, so workspace_slug is required. Matches: ${matches
.map((c) => `${c.workspace_slug}/${c.repository_slug}`)
.join(", ")}`,
};
}
return matches[0];
}
/**
* Set the branch for one repository, keeping the others in place.
*
* A repository holds at most ONE branch (the table is unique on ticket +
* workspace + repository), so linking a second branch for the same repository
* replaces the first rather than adding to it. The caller is told which name it
* displaced, because "linked" and "replaced what was there" are not the same
* outcome to report.
*/
export function mergeBranch(existing, selection, { replaceAll = false } = {}) {
const next = {
workspace_slug: text(selection?.workspace_slug),
repository_slug: text(selection?.repository_slug),
branch_name: text(selection?.branch_name),
};
const before = branchesForWrite(existing);
const key = repositoryKey(next);
const previous = before.find((row) => repositoryKey(row) === key);
const replaced =
previous && previous.branch_name !== next.branch_name
? previous.branch_name
: null;
if (replaceAll) {
return {
desired: [next],
replaced,
removedCount: before.filter((row) => repositoryKey(row) !== key).length,
};
}
return {
desired: previous
? before.map((row) => (repositoryKey(row) === key ? next : row))
: [...before, next],
replaced,
removedCount: 0,
};
}
/** Drop the branch for one repository, or all of them. */
export function removeBranch(existing, { workspace_slug, repository_slug, all = false } = {}) {
const before = branchesForWrite(existing);
if (all) {
return { desired: [], removed: before };
}
const key = repositoryKey({ workspace_slug, repository_slug });
const desired = before.filter((row) => repositoryKey(row) !== key);
return {
desired,
removed: before.filter((row) => repositoryKey(row) === key),
};
}