feat(api-tools): native TS tools for Jira/Confluence/GitLab, drop MCP bridge
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Pure REST helpers for the corporate tracker (Jira Server/DC, API v2).
|
||||
* `trackerUrl` comes from config/company.json; `JIRA_TOKEN` from the env.
|
||||
* No pi API — unit-testable (see test/api.test.ts).
|
||||
*/
|
||||
import { apiRequest, requireToken, ApiError } from "./atlassian.ts";
|
||||
import { readLocalConfig } from "./company-config.ts";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = path.join(HERE, "..", "..", "config", "company.json");
|
||||
|
||||
/** Resolve the tracker base URL — prefer company.json; env override wins. */
|
||||
export function jiraBaseUrl(): string {
|
||||
const cfg = readLocalConfig(CONFIG_PATH);
|
||||
const fromCfg = cfg.trackerUrl && cfg.trackerUrl !== "TODO" ? cfg.trackerUrl : "";
|
||||
// env TRACKER_URL may override the packaged default for local labs.
|
||||
return (process.env.TRACKER_URL && process.env.TRACKER_URL !== "TODO" ? process.env.TRACKER_URL : fromCfg).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function token(): string {
|
||||
return requireToken(
|
||||
"JIRA_TOKEN",
|
||||
"personal access token трекера. Где брать: Профиль → Безопасность → Personal Access Tokens → создать",
|
||||
);
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
key: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface JqlResult {
|
||||
issues: Issue[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// --- Endpoint helpers -------------------------------------------------------
|
||||
|
||||
export async function issueGet(key: string): Promise<Issue> {
|
||||
if (!key) throw new ApiError("Укажи ключ задачи (например PROJ-123).");
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
const data: any = await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}`, {
|
||||
query: { fields: "summary,status,description" },
|
||||
});
|
||||
return {
|
||||
key: data?.key ?? key,
|
||||
summary: data?.fields?.summary ?? "",
|
||||
status: data?.fields?.status?.name ?? "",
|
||||
description: data?.fields?.description ?? "",
|
||||
url: `${base}/browse/${data?.key ?? key}`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueCreateInput {
|
||||
projectKey: string;
|
||||
summary: string;
|
||||
issuetype: string; // e.g. "Bug", "Task", "Story"
|
||||
description?: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
assignee?: string;
|
||||
}
|
||||
|
||||
export async function issueCreate(input: IssueCreateInput): Promise<Issue> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!input.projectKey || !input.summary || !input.issuetype) {
|
||||
throw new ApiError("Для создания задачи нужны projectKey, summary и issuetype.");
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {
|
||||
project: { key: input.projectKey },
|
||||
summary: input.summary,
|
||||
issuetype: { name: input.issuetype },
|
||||
};
|
||||
if (input.description) fields.description = input.description;
|
||||
if (input.priority) fields.priority = { name: input.priority };
|
||||
if (input.labels?.length) fields.labels = input.labels;
|
||||
if (input.assignee) fields.assignee = { name: input.assignee };
|
||||
|
||||
const data: any = await apiRequest(base, token(), "/rest/api/2/issue", {
|
||||
method: "POST",
|
||||
body: { fields },
|
||||
});
|
||||
return {
|
||||
key: data?.key ?? "",
|
||||
summary: input.summary,
|
||||
status: "To Do",
|
||||
url: `${base}/browse/${data?.key ?? ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueUpdateInput {
|
||||
summary?: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
assignee?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function issueUpdate(key: string, input: IssueUpdateInput): Promise<Issue> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!key) throw new ApiError("Укажи ключ задачи.");
|
||||
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (input.summary !== undefined) fields.summary = input.summary;
|
||||
if (input.description !== undefined) fields.description = input.description;
|
||||
if (input.priority !== undefined) fields.priority = { name: input.priority };
|
||||
if (input.labels !== undefined) fields.labels = input.labels;
|
||||
if (input.assignee !== undefined) fields.assignee = { name: input.assignee };
|
||||
|
||||
if (Object.keys(fields).length > 0) {
|
||||
await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
body: { fields },
|
||||
});
|
||||
}
|
||||
|
||||
// Optional status transition in the same call (otherwise fetch current).
|
||||
if (input.status && input.status.toLowerCase() !== "noop") {
|
||||
await setStatus(key, input.status);
|
||||
}
|
||||
return issueGet(key);
|
||||
}
|
||||
|
||||
/** Translate a readable target to a known transition id, then apply it. */
|
||||
export async function setStatus(key: string, target: string): Promise<string> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
const t = target.toLowerCase();
|
||||
const alias: Record<string, string> = {
|
||||
"todo": "To Do", "to do": "To Do", "не начато": "To Do", "backlog": "To Do",
|
||||
"in progress": "In Progress", "в работе": "In Progress",
|
||||
"review": "Review", "in review": "Review", "на ревью": "Review",
|
||||
"done": "Done", "готово": "Done",
|
||||
};
|
||||
const resolved = alias[t] ?? target;
|
||||
|
||||
// Fetch available transitions and find one whose name matches.
|
||||
const data: any = await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}/transitions`);
|
||||
const transitions: Array<{ id: string; name: string }> = data?.transitions ?? [];
|
||||
if (transitions.length === 0) {
|
||||
throw new ApiError(`У задачи ${key} нет доступных переходов (или нет прав). Текущий статус не меняю.`);
|
||||
}
|
||||
const norm = (s: string) => s.toLowerCase().trim();
|
||||
const found = transitions.find((x) => norm(x.name) === norm(resolved));
|
||||
if (!found) {
|
||||
const names = transitions.map((x) => x.name).join(", ");
|
||||
throw new ApiError(`Нет перехода в «${resolved}». Доступны: ${names}.`);
|
||||
}
|
||||
await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}/transitions`, {
|
||||
method: "POST",
|
||||
body: { transition: { id: found.id } },
|
||||
});
|
||||
return found.name;
|
||||
}
|
||||
|
||||
export async function issueComment(key: string, text: string): Promise<{ id?: string; url?: string }> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!key || !text) throw new ApiError("comment требует key и текст.");
|
||||
const data: any = await apiRequest(base, token(), `/rest/api/2/issue/${encodeURIComponent(key)}/comment`, {
|
||||
method: "POST",
|
||||
body: { body: text },
|
||||
});
|
||||
return { id: data?.id, url: `${base}/browse/${key}` };
|
||||
}
|
||||
|
||||
/** JQL search; returns a compact issue list. */
|
||||
export async function search(jql: string, maxResults = 20): Promise<JqlResult> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!jql) throw new ApiError("search требует SQL-запрос (JQL).");
|
||||
const data: any = await apiRequest(base, token(), "/rest/api/2/search", {
|
||||
query: { jql, maxResults, fields: "summary,status" },
|
||||
});
|
||||
const issues: Issue[] = (data?.issues ?? []).map((it: any) => ({
|
||||
key: it.key,
|
||||
summary: it.fields?.summary ?? "",
|
||||
status: it.fields?.status?.name ?? "",
|
||||
url: `${base}/browse/${it.key}`,
|
||||
}));
|
||||
return { issues, total: data?.total ?? issues.length };
|
||||
}
|
||||
|
||||
/** Attach an MR link to a ticket (default: comment with the link). */
|
||||
export async function linkMr(key: string, mrUrl: string, title = "Merge request"): Promise<{ id?: string }> {
|
||||
const base = jiraBaseUrl();
|
||||
if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL.");
|
||||
if (!key || !mrUrl) throw new ApiError("linkMr требует key и url MR.");
|
||||
const res = await issueComment(key, `${title}: ${mrUrl}`);
|
||||
return res;
|
||||
}
|
||||
Reference in New Issue
Block a user