Files
pi-kit/extensions/lib/jira.ts
T

202 lines
7.8 KiB
TypeScript
Raw 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 } 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;
}