@danypops/tickets 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * GitLab adapter — driven implementation of IssueRepository/CommentCapable against
3
+ * the GitLab REST API v4 (docs: https://docs.gitlab.com/api/issues/).
4
+ * Self-hosted base URLs are validated to reject SSRF-prone targets (private/loopback
5
+ * IPs, non-HTTPS non-localhost) before any request is made.
6
+ */
7
+ import { isIP } from "node:net";
8
+ import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
9
+ import { parsePriority } from "../domain/issue.js";
10
+ import { AuthRequiredError, InvalidUrlError } from "./errors.js";
11
+ import { type FetchLike, HttpClient } from "./http.js";
12
+
13
+ export interface GitLabOptions {
14
+ projectId: string;
15
+ token?: string;
16
+ /**
17
+ * "private" (default): PRIVATE-TOKEN header, for personal/project access tokens.
18
+ * "oauth": Authorization: Bearer header, for OAuth 2.0 access tokens (see
19
+ * auth/gitlab-oauth.ts) — GitLab documents these as distinct auth schemes.
20
+ */
21
+ tokenType?: "private" | "oauth";
22
+ baseUrl?: string;
23
+ fetchImpl?: FetchLike;
24
+ }
25
+
26
+ interface GlUser {
27
+ username: string;
28
+ name: string;
29
+ }
30
+ interface GlIssue {
31
+ id: number;
32
+ iid: number;
33
+ title: string;
34
+ description: string | null;
35
+ state: string;
36
+ web_url: string;
37
+ author: GlUser | null;
38
+ assignee: GlUser | null;
39
+ labels: string[];
40
+ created_at: string;
41
+ updated_at: string;
42
+ }
43
+ interface GlNote {
44
+ id: number;
45
+ body: string;
46
+ created_at: string;
47
+ updated_at: string;
48
+ author: GlUser | null;
49
+ }
50
+
51
+ const DEFAULT_URL = "https://gitlab.com";
52
+
53
+ export class GitLabRepository {
54
+ readonly name: string;
55
+ private readonly http: HttpClient;
56
+ private readonly projectId: string;
57
+ private readonly readOnly: boolean;
58
+
59
+ constructor(name: string, opts: GitLabOptions) {
60
+ if (!opts.projectId) throw new Error("gitlab: projectId is required");
61
+ const baseUrl = opts.baseUrl?.trim() || DEFAULT_URL;
62
+ validateUrl(baseUrl);
63
+ this.name = name;
64
+ this.projectId = encodeURIComponent(opts.projectId);
65
+ this.readOnly = !opts.token;
66
+ this.http = new HttpClient({
67
+ baseUrl,
68
+ backend: "gitlab",
69
+ fetchImpl: opts.fetchImpl,
70
+ headers: opts.token
71
+ ? opts.tokenType === "oauth"
72
+ ? { Authorization: `Bearer ${opts.token}` }
73
+ : { "PRIVATE-TOKEN": opts.token }
74
+ : {},
75
+ });
76
+ }
77
+
78
+ private requireAuth(): void {
79
+ if (this.readOnly) throw new AuthRequiredError("gitlab", "GITLAB_TOKEN");
80
+ }
81
+
82
+ async list(filter: ListFilter): Promise<Issue[]> {
83
+ const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
84
+ const params = new URLSearchParams({ per_page: String(limit) });
85
+ if (filter.status) params.set("state", mapStatusToGitLab(filter.status));
86
+ if (filter.assignee) params.set("assignee_username", filter.assignee);
87
+ if (filter.labels?.length) params.set("labels", filter.labels.join(","));
88
+
89
+ const raw = (await this.http.get<GlIssue[]>(`/api/v4/projects/${this.projectId}/issues?${params}`)) ?? [];
90
+ return raw.map(toDomain);
91
+ }
92
+
93
+ async get(key: string): Promise<Issue> {
94
+ const iid = parseIid(key);
95
+ const raw = await this.http.get<GlIssue>(`/api/v4/projects/${this.projectId}/issues/${iid}`);
96
+ if (!raw) throw new Error(`gitlab: empty response for #${iid}`);
97
+ return toDomain(raw);
98
+ }
99
+
100
+ async create(input: CreateInput): Promise<Issue> {
101
+ this.requireAuth();
102
+ const body: Record<string, unknown> = { title: input.title, description: input.description ?? "" };
103
+ if (input.labels?.length) body.labels = input.labels.join(",");
104
+ const raw = await this.http.post<GlIssue>(`/api/v4/projects/${this.projectId}/issues`, body);
105
+ if (!raw) throw new Error("gitlab: create returned no body");
106
+ return toDomain(raw);
107
+ }
108
+
109
+ async update(key: string, input: UpdateInput): Promise<Issue> {
110
+ this.requireAuth();
111
+ const iid = parseIid(key);
112
+ const body: Record<string, unknown> = {};
113
+ if (input.title !== undefined) body.title = input.title;
114
+ if (input.description !== undefined) body.description = input.description;
115
+ if (input.status !== undefined) body.state_event = mapStatusEventToGitLab(input.status);
116
+ if (input.labels !== undefined) body.labels = input.labels.join(",");
117
+ const raw = await this.http.put<GlIssue>(`/api/v4/projects/${this.projectId}/issues/${iid}`, body);
118
+ if (!raw) throw new Error("gitlab: update returned no body");
119
+ return toDomain(raw);
120
+ }
121
+
122
+ async search(query: string, limit = 50): Promise<Issue[]> {
123
+ const params = new URLSearchParams({ search: query, per_page: String(limit) });
124
+ const raw = (await this.http.get<GlIssue[]>(`/api/v4/projects/${this.projectId}/issues?${params}`)) ?? [];
125
+ return raw.map(toDomain);
126
+ }
127
+
128
+ // GitLab has no native sub-issue relationship exposed via the basic Issues API tier.
129
+ async listChildren(_key: string): Promise<Issue[]> {
130
+ return [];
131
+ }
132
+
133
+ async listComments(key: string): Promise<Comment[]> {
134
+ const iid = parseIid(key);
135
+ const raw = (await this.http.get<GlNote[]>(`/api/v4/projects/${this.projectId}/issues/${iid}/notes`)) ?? [];
136
+ return raw.map(noteToDomain);
137
+ }
138
+
139
+ async addComment(key: string, body: string): Promise<Comment> {
140
+ this.requireAuth();
141
+ const iid = parseIid(key);
142
+ const raw = await this.http.post<GlNote>(`/api/v4/projects/${this.projectId}/issues/${iid}/notes`, { body });
143
+ if (!raw) throw new Error("gitlab: add comment returned no body");
144
+ return noteToDomain(raw);
145
+ }
146
+ }
147
+
148
+ function parseIid(key: string): string {
149
+ return key.replace(/^#/, "");
150
+ }
151
+
152
+ function mapStatusToGitLab(status: Status): "opened" | "closed" {
153
+ return status === "done" || status === "canceled" ? "closed" : "opened";
154
+ }
155
+
156
+ function mapStatusEventToGitLab(status: Status): "close" | "reopen" {
157
+ return status === "done" || status === "canceled" ? "close" : "reopen";
158
+ }
159
+
160
+ function mapStatusFromGitLab(state: string): Status {
161
+ return state.toLowerCase() === "closed" ? "done" : "todo";
162
+ }
163
+
164
+ function priorityFromLabels(labels: string[]): ReturnType<typeof parsePriority> {
165
+ for (const l of labels) {
166
+ const lower = l.toLowerCase();
167
+ if (lower.includes("urgent") || lower.includes("critical")) return "urgent";
168
+ if (lower.includes("high")) return "high";
169
+ if (lower.includes("medium")) return "medium";
170
+ if (lower.includes("low")) return "low";
171
+ }
172
+ return "none";
173
+ }
174
+
175
+ function toDomain(gl: GlIssue): Issue {
176
+ return {
177
+ ref: `gitlab:#${gl.iid}`,
178
+ id: String(gl.iid),
179
+ key: `#${gl.iid}`,
180
+ title: gl.title,
181
+ description: gl.description ?? undefined,
182
+ status: mapStatusFromGitLab(gl.state),
183
+ rawStatus: gl.state,
184
+ priority: priorityFromLabels(gl.labels ?? []),
185
+ labels: gl.labels?.length ? gl.labels : undefined,
186
+ assignee: gl.assignee?.username,
187
+ reporter: gl.author?.username,
188
+ url: gl.web_url,
189
+ createdAt: gl.created_at,
190
+ updatedAt: gl.updated_at,
191
+ };
192
+ }
193
+
194
+ function noteToDomain(n: GlNote): Comment {
195
+ return {
196
+ id: String(n.id),
197
+ body: n.body,
198
+ author: n.author?.username,
199
+ createdAt: n.created_at,
200
+ updatedAt: n.updated_at,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * SSRF guard for self-hosted GitLab base URLs: require https (or http only for
206
+ * localhost), and reject requests aimed at loopback/link-local/private IP ranges.
207
+ */
208
+ export function validateUrl(rawUrl: string): void {
209
+ let parsed: URL;
210
+ try {
211
+ parsed = new URL(rawUrl);
212
+ } catch {
213
+ throw new InvalidUrlError(`gitlab: invalid URL: ${rawUrl}`);
214
+ }
215
+
216
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
217
+ throw new InvalidUrlError(`gitlab: scheme must be http(s) (got ${parsed.protocol})`);
218
+ }
219
+ if (parsed.protocol === "http:" && parsed.hostname !== "localhost") {
220
+ throw new InvalidUrlError(
221
+ `gitlab: http:// only allowed for localhost (got ${parsed.hostname}); use https:// for remote instances`,
222
+ );
223
+ }
224
+ if (isIP(parsed.hostname) && isPrivateIp(parsed.hostname)) {
225
+ throw new InvalidUrlError("gitlab: private IP addresses are not allowed (blocks SSRF)");
226
+ }
227
+ }
228
+
229
+ function isPrivateIp(ip: string): boolean {
230
+ if (ip === "127.0.0.1" || ip === "::1") return true;
231
+ if (/^169\.254\./.test(ip) || ip.startsWith("fe80:")) return true;
232
+ if (/^10\./.test(ip)) return true;
233
+ if (/^192\.168\./.test(ip)) return true;
234
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
235
+ if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true;
236
+ return false;
237
+ }
@@ -0,0 +1,86 @@
1
+ import { ApiError, IssueNotFoundError } from "./errors.js";
2
+
3
+ export type FetchLike = typeof fetch;
4
+
5
+ export interface HttpClientOptions {
6
+ baseUrl: string;
7
+ backend: string;
8
+ headers?: Record<string, string>;
9
+ fetchImpl?: FetchLike;
10
+ timeoutMs?: number;
11
+ }
12
+
13
+ /**
14
+ * Thin authenticated JSON HTTP client shared by all adapters. Each adapter owns its
15
+ * own auth header construction (Bearer, Basic, PRIVATE-TOKEN, ...) and passes it in
16
+ * via `headers`. Accepts an injectable `fetchImpl` so adapters are testable without
17
+ * a network — see test/adapters/*.test.ts.
18
+ */
19
+ export class HttpClient {
20
+ private readonly baseUrl: string;
21
+ private readonly backend: string;
22
+ private readonly headers: Record<string, string>;
23
+ private readonly fetchImpl: FetchLike;
24
+ private readonly timeoutMs: number;
25
+
26
+ constructor(opts: HttpClientOptions) {
27
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
28
+ this.backend = opts.backend;
29
+ this.headers = opts.headers ?? {};
30
+ this.fetchImpl = opts.fetchImpl ?? fetch;
31
+ this.timeoutMs = opts.timeoutMs ?? 30_000;
32
+ }
33
+
34
+ async request<T>(method: string, path: string, body?: unknown): Promise<T | undefined> {
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
37
+ try {
38
+ const res = await this.fetchImpl(this.baseUrl + path, {
39
+ method,
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ Accept: "application/json",
43
+ ...this.headers,
44
+ },
45
+ body: body === undefined ? undefined : JSON.stringify(body),
46
+ signal: controller.signal,
47
+ });
48
+
49
+ if (res.status === 404) {
50
+ throw new IssueNotFoundError(this.backend, path);
51
+ }
52
+ if (res.status === 204) {
53
+ return undefined;
54
+ }
55
+
56
+ const text = await res.text();
57
+ if (!res.ok) {
58
+ throw new ApiError(this.backend, method, path, res.status, redact(text));
59
+ }
60
+ if (text.length === 0) return undefined;
61
+ return JSON.parse(text) as T;
62
+ } finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
66
+
67
+ get<T>(path: string): Promise<T | undefined> {
68
+ return this.request<T>("GET", path);
69
+ }
70
+ post<T>(path: string, body?: unknown): Promise<T | undefined> {
71
+ return this.request<T>("POST", path, body);
72
+ }
73
+ put<T>(path: string, body?: unknown): Promise<T | undefined> {
74
+ return this.request<T>("PUT", path, body);
75
+ }
76
+ patch<T>(path: string, body?: unknown): Promise<T | undefined> {
77
+ return this.request<T>("PATCH", path, body);
78
+ }
79
+ }
80
+
81
+ /** Strips anything that looks like a bearer/basic credential from error bodies before logging. */
82
+ function redact(text: string): string {
83
+ return text
84
+ .replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"')
85
+ .slice(0, 2000);
86
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Jira adapter — driven implementation of IssueRepository/CommentCapable against the
3
+ * Jira Cloud/Server REST API v2 (docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/).
4
+ * Status changes go through Jira's workflow transitions, not a direct field PUT —
5
+ * Jira statuses are workflow-owned, so we resolve the transition whose name matches
6
+ * the mapped target status and post to it.
7
+ */
8
+ import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
9
+ import { parsePriority } from "../domain/issue.js";
10
+ import { type FetchLike, HttpClient } from "./http.js";
11
+
12
+ /**
13
+ * Basic-auth mode (email + API token) hits the tenant's own *.atlassian.net
14
+ * domain directly. OAuth 2.0 (3LO) mode (accessToken + cloudId, see
15
+ * auth/jira-oauth.ts) instead goes through api.atlassian.com/ex/jira/{cloudId}
16
+ * with a Bearer token — Atlassian does not accept 3LO tokens against the
17
+ * tenant domain directly. Exactly one of the two auth modes must be given.
18
+ */
19
+ export type JiraOptions = JiraBasicAuthOptions | JiraOAuthOptions;
20
+
21
+ export interface JiraBasicAuthOptions {
22
+ baseUrl: string;
23
+ email: string;
24
+ token: string;
25
+ project?: string;
26
+ fetchImpl?: FetchLike;
27
+ }
28
+
29
+ export interface JiraOAuthOptions {
30
+ accessToken: string;
31
+ cloudId: string;
32
+ project?: string;
33
+ fetchImpl?: FetchLike;
34
+ }
35
+
36
+ function isOAuthOptions(opts: JiraOptions): opts is JiraOAuthOptions {
37
+ return "accessToken" in opts;
38
+ }
39
+
40
+ interface JiraIssueFields {
41
+ summary: string;
42
+ description?: string | null;
43
+ status: { name: string; statusCategory?: { key: string } };
44
+ priority?: { name: string } | null;
45
+ assignee?: { displayName: string } | null;
46
+ reporter?: { displayName: string } | null;
47
+ labels?: string[];
48
+ project?: { key: string };
49
+ issuetype?: { name: string };
50
+ resolution?: { name: string } | null;
51
+ parent?: { key: string; fields?: { summary: string; status?: { name: string } } };
52
+ created?: string;
53
+ updated?: string;
54
+ }
55
+ interface JiraIssue {
56
+ id: string;
57
+ key: string;
58
+ self: string;
59
+ fields: JiraIssueFields;
60
+ }
61
+ interface JiraTransition {
62
+ id: string;
63
+ name: string;
64
+ }
65
+ interface JiraComment {
66
+ id: string;
67
+ body: string;
68
+ created: string;
69
+ updated: string;
70
+ author?: { displayName: string };
71
+ }
72
+
73
+ export class JiraRepository {
74
+ readonly name: string;
75
+ private readonly http: HttpClient;
76
+ private readonly project?: string;
77
+
78
+ constructor(name: string, opts: JiraOptions) {
79
+ this.name = name;
80
+ this.project = opts.project;
81
+
82
+ if (isOAuthOptions(opts)) {
83
+ if (!opts.accessToken || !opts.cloudId) throw new Error("jira: accessToken and cloudId are required for OAuth mode");
84
+ this.http = new HttpClient({
85
+ baseUrl: `https://api.atlassian.com/ex/jira/${opts.cloudId}`,
86
+ backend: "jira",
87
+ fetchImpl: opts.fetchImpl,
88
+ headers: { Authorization: `Bearer ${opts.accessToken}` },
89
+ });
90
+ return;
91
+ }
92
+
93
+ if (!opts.baseUrl) throw new Error("jira: baseUrl is required");
94
+ if (!opts.email || !opts.token) throw new Error("jira: email and token are required");
95
+ const basic = Buffer.from(`${opts.email}:${opts.token}`).toString("base64");
96
+ this.http = new HttpClient({
97
+ baseUrl: opts.baseUrl,
98
+ backend: "jira",
99
+ fetchImpl: opts.fetchImpl,
100
+ headers: { Authorization: `Basic ${basic}` },
101
+ });
102
+ }
103
+
104
+ async list(filter: ListFilter): Promise<Issue[]> {
105
+ const project = filter.project ?? this.project;
106
+ const clauses: string[] = [];
107
+ if (project) clauses.push(`project = ${jqlQuote(project)}`);
108
+ if (filter.status) clauses.push(`status = ${jqlQuote(mapStatusToJira(filter.status))}`);
109
+ if (filter.assignee) clauses.push(`assignee = ${jqlQuote(filter.assignee)}`);
110
+ for (const label of filter.labels ?? []) clauses.push(`labels = ${jqlQuote(label)}`);
111
+ const jql = `${clauses.join(" AND ")} ORDER BY created DESC`.trim();
112
+ return this.searchJql(jql, filter.limit ?? 50);
113
+ }
114
+
115
+ async get(key: string): Promise<Issue> {
116
+ const raw = await this.http.get<JiraIssue>(`/rest/api/2/issue/${key}`);
117
+ if (!raw) throw new Error(`jira: empty response for ${key}`);
118
+ return toDomain(raw);
119
+ }
120
+
121
+ async create(input: CreateInput): Promise<Issue> {
122
+ const project = input.project ?? this.project;
123
+ if (!project) throw new Error("jira: project is required (pass project or set a default)");
124
+ const fields: Record<string, unknown> = {
125
+ project: { key: project },
126
+ summary: input.title,
127
+ issuetype: { name: input.issueType ?? "Task" },
128
+ };
129
+ if (input.description) fields.description = input.description;
130
+ if (input.labels?.length) fields.labels = input.labels;
131
+ if (input.parentKey) fields.parent = { key: input.parentKey };
132
+ if (input.priority && input.priority !== "none") {
133
+ fields.priority = { name: mapPriorityToJira(input.priority) };
134
+ }
135
+ const result = await this.http.post<{ key: string }>("/rest/api/2/issue", { fields });
136
+ if (!result) throw new Error("jira: create returned no body");
137
+ return this.get(result.key);
138
+ }
139
+
140
+ async update(key: string, input: UpdateInput): Promise<Issue> {
141
+ const fields: Record<string, unknown> = {};
142
+ if (input.title !== undefined) fields.summary = input.title;
143
+ if (input.description !== undefined) fields.description = input.description;
144
+ if (input.priority !== undefined) fields.priority = { name: mapPriorityToJira(input.priority) };
145
+ if (input.labels !== undefined) fields.labels = input.labels;
146
+ if (Object.keys(fields).length > 0) {
147
+ await this.http.put(`/rest/api/2/issue/${key}`, { fields });
148
+ }
149
+ if (input.status !== undefined) {
150
+ await this.transitionTo(key, input.status, input.resolution);
151
+ }
152
+ return this.get(key);
153
+ }
154
+
155
+ async search(query: string, limit = 50): Promise<Issue[]> {
156
+ const scope = this.project ? `project = ${jqlQuote(this.project)} AND ` : "";
157
+ const jql = `${scope}text ~ ${jqlQuote(query)} ORDER BY created DESC`;
158
+ return this.searchJql(jql, limit);
159
+ }
160
+
161
+ async listChildren(key: string): Promise<Issue[]> {
162
+ return this.searchJql(`parent = ${key} ORDER BY created ASC`, 50);
163
+ }
164
+
165
+ async listComments(key: string): Promise<Comment[]> {
166
+ const result = await this.http.get<{ comments: JiraComment[] }>(`/rest/api/2/issue/${key}/comment`);
167
+ return (result?.comments ?? []).map(commentToDomain);
168
+ }
169
+
170
+ async addComment(key: string, body: string): Promise<Comment> {
171
+ const raw = await this.http.post<JiraComment>(`/rest/api/2/issue/${key}/comment`, { body });
172
+ if (!raw) throw new Error("jira: add comment returned no body");
173
+ return commentToDomain(raw);
174
+ }
175
+
176
+ private async searchJql(jql: string, limit: number): Promise<Issue[]> {
177
+ const result = await this.http.post<{ issues: JiraIssue[] }>("/rest/api/2/search", {
178
+ jql,
179
+ maxResults: limit,
180
+ });
181
+ return (result?.issues ?? []).map(toDomain);
182
+ }
183
+
184
+ private async transitionTo(key: string, status: Status, resolution?: string): Promise<void> {
185
+ const target = mapStatusToJira(status);
186
+ const result = await this.http.get<{ transitions: JiraTransition[] }>(
187
+ `/rest/api/2/issue/${key}/transitions`,
188
+ );
189
+ const transitions = result?.transitions ?? [];
190
+ const match = transitions.find((t) => t.name.toLowerCase() === target.toLowerCase());
191
+ if (!match) {
192
+ const available = transitions.map((t) => t.name).join(", ");
193
+ throw new Error(`jira: no transition matching "${target}" (available: ${available})`);
194
+ }
195
+ const body: Record<string, unknown> = { transition: { id: match.id } };
196
+ if (resolution) body.fields = { resolution: { name: resolution } };
197
+ await this.http.post(`/rest/api/2/issue/${key}/transitions`, body);
198
+ }
199
+ }
200
+
201
+ function jqlQuote(value: string): string {
202
+ return `"${value.replace(/"/g, '\\"')}"`;
203
+ }
204
+
205
+ function mapStatusToJira(status: Status): string {
206
+ switch (status) {
207
+ case "backlog":
208
+ return "Backlog";
209
+ case "todo":
210
+ return "New";
211
+ case "in_progress":
212
+ return "In Progress";
213
+ case "in_review":
214
+ return "In Review";
215
+ case "done":
216
+ return "Done";
217
+ case "canceled":
218
+ return "Closed";
219
+ default:
220
+ return "New";
221
+ }
222
+ }
223
+
224
+ function mapStatusFromJira(categoryKey: string | undefined): Status {
225
+ switch (categoryKey) {
226
+ case "new":
227
+ return "todo";
228
+ case "indeterminate":
229
+ return "in_progress";
230
+ case "done":
231
+ return "done";
232
+ default:
233
+ return "backlog";
234
+ }
235
+ }
236
+
237
+ function mapPriorityToJira(p: ReturnType<typeof parsePriority>): string {
238
+ switch (p) {
239
+ case "urgent":
240
+ return "Critical";
241
+ case "high":
242
+ return "Major";
243
+ case "medium":
244
+ return "Normal";
245
+ case "low":
246
+ return "Minor";
247
+ default:
248
+ return "Normal";
249
+ }
250
+ }
251
+
252
+ function mapPriorityFromJira(name: string | undefined): ReturnType<typeof parsePriority> {
253
+ if (!name) return "none";
254
+ switch (name.toLowerCase()) {
255
+ case "blocker":
256
+ case "critical":
257
+ return "urgent";
258
+ case "major":
259
+ return "high";
260
+ case "normal":
261
+ case "minor":
262
+ return "medium";
263
+ case "trivial":
264
+ return "low";
265
+ default:
266
+ return "none";
267
+ }
268
+ }
269
+
270
+ function toDomain(j: JiraIssue): Issue {
271
+ const issue: Issue = {
272
+ ref: `jira:${j.key}`,
273
+ id: j.id,
274
+ key: j.key,
275
+ title: j.fields.summary,
276
+ description: j.fields.description ?? undefined,
277
+ status: mapStatusFromJira(j.fields.status.statusCategory?.key),
278
+ rawStatus: j.fields.status.name,
279
+ priority: mapPriorityFromJira(j.fields.priority?.name),
280
+ labels: j.fields.labels?.length ? j.fields.labels : undefined,
281
+ assignee: j.fields.assignee?.displayName,
282
+ reporter: j.fields.reporter?.displayName,
283
+ project: j.fields.project?.key,
284
+ issueType: j.fields.issuetype?.name,
285
+ resolution: j.fields.resolution?.name,
286
+ createdAt: j.fields.created,
287
+ updatedAt: j.fields.updated,
288
+ };
289
+ if (j.fields.parent) {
290
+ issue.parent = {
291
+ key: j.fields.parent.key,
292
+ title: j.fields.parent.fields?.summary ?? "",
293
+ status: j.fields.parent.fields?.status?.name,
294
+ };
295
+ }
296
+ if (j.self) {
297
+ const idx = j.self.indexOf("/rest/");
298
+ if (idx > 0) issue.url = `${j.self.slice(0, idx)}/browse/${j.key}`;
299
+ }
300
+ return issue;
301
+ }
302
+
303
+ function commentToDomain(c: JiraComment): Comment {
304
+ return {
305
+ id: c.id,
306
+ body: c.body,
307
+ author: c.author?.displayName,
308
+ createdAt: c.created,
309
+ updatedAt: c.updated,
310
+ };
311
+ }