/** * 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, resolveBaseUrl } 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 — company.json default, env override wins. */ export function jiraBaseUrl(): string { const cfg = readLocalConfig(CONFIG_PATH); // env TRACKER_URL may override the packaged default for local labs. return resolveBaseUrl("TRACKER_URL", cfg.trackerUrl); } 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 { 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 { 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 = { 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 { const base = jiraBaseUrl(); if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL."); if (!key) throw new ApiError("Укажи ключ задачи."); const fields: Record = {}; 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 { const base = jiraBaseUrl(); if (!base) throw new ApiError("Не задан trackerUrl в config/company.json и нет TRACKER_URL."); const t = target.toLowerCase(); const alias: Record = { "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 { 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; }