Files
Aleksey Shakhmatov 0c6e590273 refactor(extensions): simplify lib helpers, tool registration and kit-doctor
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.
2026-08-07 00:24:04 +03:00

201 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}