feat(atlassian): implement milestone M4 - packaging and doc sync
This commit is contained in:
264
skills/atlassian/opencode/scripts/src/jira.ts
Normal file
264
skills/atlassian/opencode/scripts/src/jira.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user