Behavior-preserving cleanups: - extract shared resolveBaseUrl helper for jira/confluence base-URL logic - extract shared registerTool helper; port jira/gitlab/confluence tools to it - make scanSecrets declarative (filter/map) - collapse ok/warn pair into status() and drop double existsSync in kit-doctor Typecheck clean; npm test 71 PASS, 0 FAIL.
201 lines
7.7 KiB
TypeScript
201 lines
7.7 KiB
TypeScript
/**
|
||
* 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<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;
|
||
}
|