265 lines
7.1 KiB
TypeScript
265 lines
7.1 KiB
TypeScript
import { markdownToAdf } from "./adf.js";
|
|
import { sendJsonRequest } from "./http.js";
|
|
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
|
|
|
|
const ISSUE_FIELDS = ["summary", "issuetype", "status", "assignee", "created", "updated"] as const;
|
|
|
|
type JiraClientOptions = {
|
|
config: AtlassianConfig;
|
|
fetchImpl?: FetchLike;
|
|
};
|
|
|
|
type SearchInput = {
|
|
jql: string;
|
|
maxResults: number;
|
|
startAt: number;
|
|
};
|
|
|
|
type CreateInput = {
|
|
project?: string;
|
|
type: string;
|
|
summary: string;
|
|
description?: string;
|
|
dryRun?: boolean;
|
|
};
|
|
|
|
type UpdateInput = {
|
|
issue: string;
|
|
summary?: string;
|
|
description?: string;
|
|
dryRun?: boolean;
|
|
};
|
|
|
|
type CommentInput = {
|
|
issue: string;
|
|
body: string;
|
|
dryRun?: boolean;
|
|
};
|
|
|
|
type TransitionInput = {
|
|
issue: string;
|
|
transition: string;
|
|
dryRun?: boolean;
|
|
};
|
|
|
|
function normalizeIssue(config: AtlassianConfig, issue: Record<string, unknown>): JiraIssueSummary {
|
|
const fields = (issue.fields ?? {}) as Record<string, unknown>;
|
|
const issueType = (fields.issuetype ?? {}) as Record<string, unknown>;
|
|
const status = (fields.status ?? {}) as Record<string, unknown>;
|
|
const assignee = (fields.assignee ?? {}) as Record<string, unknown>;
|
|
|
|
return {
|
|
key: String(issue.key ?? ""),
|
|
summary: String(fields.summary ?? ""),
|
|
issueType: String(issueType.name ?? ""),
|
|
status: String(status.name ?? ""),
|
|
assignee: assignee.displayName ? String(assignee.displayName) : undefined,
|
|
created: String(fields.created ?? ""),
|
|
updated: String(fields.updated ?? ""),
|
|
url: `${config.baseUrl}/browse/${issue.key ?? ""}`,
|
|
};
|
|
}
|
|
|
|
function createRequest(config: AtlassianConfig, method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
|
|
const url = new URL(path, `${config.jiraBaseUrl}/`);
|
|
|
|
return {
|
|
method,
|
|
url: url.toString(),
|
|
...(body === undefined ? {} : { body }),
|
|
};
|
|
}
|
|
|
|
export function createJiraClient(options: JiraClientOptions) {
|
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
|
|
if (!fetchImpl) {
|
|
throw new Error("Fetch API is not available in this runtime");
|
|
}
|
|
|
|
async function send(method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
|
|
const request = createRequest(options.config, method, path, body);
|
|
return sendJsonRequest({
|
|
config: options.config,
|
|
fetchImpl,
|
|
url: request.url,
|
|
method,
|
|
body,
|
|
errorPrefix: "Jira request failed",
|
|
});
|
|
}
|
|
|
|
return {
|
|
async searchIssues(input: SearchInput): Promise<CommandOutput<unknown>> {
|
|
const raw = (await send("POST", "/rest/api/3/search", {
|
|
jql: input.jql,
|
|
maxResults: input.maxResults,
|
|
startAt: input.startAt,
|
|
fields: [...ISSUE_FIELDS],
|
|
})) as Record<string, unknown>;
|
|
|
|
const issues = Array.isArray(raw.issues) ? raw.issues : [];
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
issues: issues.map((issue) => normalizeIssue(options.config, issue as Record<string, unknown>)),
|
|
startAt: Number(raw.startAt ?? input.startAt),
|
|
maxResults: Number(raw.maxResults ?? input.maxResults),
|
|
total: Number(raw.total ?? issues.length),
|
|
},
|
|
};
|
|
},
|
|
|
|
async getIssue(issue: string): Promise<CommandOutput<unknown>> {
|
|
const url = new URL(`/rest/api/3/issue/${issue}`, `${options.config.jiraBaseUrl}/`);
|
|
url.searchParams.set("fields", ISSUE_FIELDS.join(","));
|
|
|
|
const raw = (await send("GET", `${url.pathname}${url.search}`)) as Record<string, unknown>;
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
issue: normalizeIssue(options.config, raw),
|
|
},
|
|
raw,
|
|
};
|
|
},
|
|
|
|
async getTransitions(issue: string): Promise<CommandOutput<unknown>> {
|
|
const raw = (await send(
|
|
"GET",
|
|
`/rest/api/3/issue/${issue}/transitions`,
|
|
)) as { transitions?: Array<Record<string, unknown>> };
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
transitions: (raw.transitions ?? []).map((transition) => ({
|
|
id: String(transition.id ?? ""),
|
|
name: String(transition.name ?? ""),
|
|
toStatus: String(((transition.to ?? {}) as Record<string, unknown>).name ?? ""),
|
|
hasScreen: Boolean(transition.hasScreen),
|
|
})),
|
|
},
|
|
};
|
|
},
|
|
|
|
async createIssue(input: CreateInput): Promise<CommandOutput<unknown>> {
|
|
const project = input.project || options.config.defaultProject;
|
|
|
|
if (!project) {
|
|
throw new Error("jira-create requires --project or ATLASSIAN_DEFAULT_PROJECT");
|
|
}
|
|
|
|
const request = createRequest(options.config, "POST", "/rest/api/3/issue", {
|
|
fields: {
|
|
project: { key: project },
|
|
issuetype: { name: input.type },
|
|
summary: input.summary,
|
|
...(input.description ? { description: markdownToAdf(input.description) } : {}),
|
|
},
|
|
});
|
|
|
|
if (input.dryRun) {
|
|
return {
|
|
ok: true,
|
|
dryRun: true,
|
|
data: request,
|
|
};
|
|
}
|
|
|
|
const raw = await send("POST", "/rest/api/3/issue", request.body);
|
|
return { ok: true, data: raw };
|
|
},
|
|
|
|
async updateIssue(input: UpdateInput): Promise<CommandOutput<unknown>> {
|
|
const fields: Record<string, unknown> = {};
|
|
|
|
if (input.summary) {
|
|
fields.summary = input.summary;
|
|
}
|
|
|
|
if (input.description) {
|
|
fields.description = markdownToAdf(input.description);
|
|
}
|
|
|
|
if (Object.keys(fields).length === 0) {
|
|
throw new Error("jira-update requires --summary and/or --description-file");
|
|
}
|
|
|
|
const request = createRequest(options.config, "PUT", `/rest/api/3/issue/${input.issue}`, {
|
|
fields,
|
|
});
|
|
|
|
if (input.dryRun) {
|
|
return {
|
|
ok: true,
|
|
dryRun: true,
|
|
data: request,
|
|
};
|
|
}
|
|
|
|
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
issue: input.issue,
|
|
updated: true,
|
|
},
|
|
};
|
|
},
|
|
|
|
async commentIssue(input: CommentInput): Promise<CommandOutput<unknown>> {
|
|
const request = createRequest(options.config, "POST", `/rest/api/3/issue/${input.issue}/comment`, {
|
|
body: markdownToAdf(input.body),
|
|
});
|
|
|
|
if (input.dryRun) {
|
|
return {
|
|
ok: true,
|
|
dryRun: true,
|
|
data: request,
|
|
};
|
|
}
|
|
|
|
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
|
|
return {
|
|
ok: true,
|
|
data: raw,
|
|
};
|
|
},
|
|
|
|
async transitionIssue(input: TransitionInput): Promise<CommandOutput<unknown>> {
|
|
const request = createRequest(
|
|
options.config,
|
|
"POST",
|
|
`/rest/api/3/issue/${input.issue}/transitions`,
|
|
{
|
|
transition: {
|
|
id: input.transition,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (input.dryRun) {
|
|
return {
|
|
ok: true,
|
|
dryRun: true,
|
|
data: request,
|
|
};
|
|
}
|
|
|
|
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
issue: input.issue,
|
|
transitioned: true,
|
|
transition: input.transition,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|