UNPKG

@bitpeakdev/suporta-mcp-server

Version:

MCP server for Suporta Helpdesk — manage tickets from Claude Code

263 lines (223 loc) • 8.81 kB
/** * 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), }; }