@elyracode/youtrack 0.8.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [0.8.1] - 2026-05-27
4
+
5
+ ### Added
6
+ - Initial release with 18 YouTrack tools covering issues, comments, tags, links, projects, users, articles, and time tracking
7
+ - `elyra-youtrack` skill for YouTrack query syntax and workflow guidance
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @elyracode/youtrack
2
+
3
+ YouTrack integration for Elyra. Search issues, manage assignments, add comments, track time, browse projects and articles, and more -- all from within your Elyra session.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ elyra install npm:@elyracode/youtrack
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Set the following environment variables (or add them to your `.env` file):
14
+
15
+ | Variable | Description |
16
+ |----------|-------------|
17
+ | `YOUTRACK_BASE_URL` | Your YouTrack instance URL (e.g. `https://myteam.youtrack.cloud`) |
18
+ | `YOUTRACK_TOKEN` | Permanent token from YouTrack (Profile > Authentication > New Token) |
19
+
20
+ ## Tools
21
+
22
+ | Tool | Description |
23
+ |------|-------------|
24
+ | `youtrack_search_issues` | Search issues using YouTrack query syntax |
25
+ | `youtrack_get_issue` | Get full details of a single issue by ID |
26
+ | `youtrack_create_issue` | Create a new issue in a project |
27
+ | `youtrack_update_issue` | Update fields on an existing issue |
28
+ | `youtrack_delete_issue` | Delete an issue |
29
+ | `youtrack_list_comments` | List comments on an issue |
30
+ | `youtrack_add_comment` | Add a comment to an issue |
31
+ | `youtrack_list_tags` | List available tags |
32
+ | `youtrack_apply_tag` | Apply or remove a tag on an issue |
33
+ | `youtrack_list_links` | List links on an issue (duplicates, subtasks, related) |
34
+ | `youtrack_add_link` | Add a link between two issues |
35
+ | `youtrack_list_projects` | List all accessible projects |
36
+ | `youtrack_get_project` | Get project details and custom field schema |
37
+ | `youtrack_list_users` | List users in the YouTrack instance |
38
+ | `youtrack_get_current_user` | Get the authenticated user's profile |
39
+ | `youtrack_search_articles` | Search knowledge base articles |
40
+ | `youtrack_get_article` | Get a knowledge base article by ID |
41
+ | `youtrack_add_work_item` | Add a time tracking work item to an issue |
42
+
43
+ ## Skill
44
+
45
+ This package includes the `elyra-youtrack` skill, which provides the agent with YouTrack query syntax reference and workflow guidance. It is loaded automatically when the extension is installed.
46
+
47
+ ## Requirements
48
+
49
+ - A YouTrack instance (Cloud or self-hosted)
50
+ - A permanent token with appropriate permissions
@@ -0,0 +1,717 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@elyracode/coding-agent";
4
+ import { Type } from "typebox";
5
+
6
+ // ── Configuration ───────────────────────────────────────────────────────────
7
+
8
+ interface YouTrackConfig {
9
+ baseUrl: string;
10
+ token: string;
11
+ }
12
+
13
+ function loadEnv(cwd: string): Record<string, string> {
14
+ const envPath = join(cwd, ".env");
15
+ if (!existsSync(envPath)) return {};
16
+ const result: Record<string, string> = {};
17
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed || trimmed.startsWith("#")) continue;
20
+ const eq = trimmed.indexOf("=");
21
+ if (eq < 0) continue;
22
+ const key = trimmed.slice(0, eq).trim();
23
+ let value = trimmed.slice(eq + 1).trim();
24
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
25
+ value = value.slice(1, -1);
26
+ }
27
+ result[key] = value;
28
+ }
29
+ return result;
30
+ }
31
+
32
+ function getConfig(cwd: string): YouTrackConfig | null {
33
+ const env = loadEnv(cwd);
34
+ const baseUrl = process.env.YOUTRACK_BASE_URL || env.YOUTRACK_BASE_URL;
35
+ const token = process.env.YOUTRACK_TOKEN || env.YOUTRACK_TOKEN;
36
+ if (!baseUrl || !token) return null;
37
+ return { baseUrl: baseUrl.replace(/\/+$/, ""), token };
38
+ }
39
+
40
+ // ── HTTP Client ─────────────────────────────────────────────────────────────
41
+
42
+ async function ytFetch(
43
+ config: YouTrackConfig,
44
+ method: string,
45
+ path: string,
46
+ body?: unknown,
47
+ ): Promise<{ ok: boolean; status: number; data: unknown; text: string }> {
48
+ const url = `${config.baseUrl}${path}`;
49
+ const headers: Record<string, string> = {
50
+ Authorization: `Bearer ${config.token}`,
51
+ Accept: "application/json",
52
+ };
53
+ if (body !== undefined) {
54
+ headers["Content-Type"] = "application/json";
55
+ }
56
+ const response = await fetch(url, {
57
+ method,
58
+ headers,
59
+ body: body !== undefined ? JSON.stringify(body) : undefined,
60
+ signal: AbortSignal.timeout(30_000),
61
+ });
62
+ const text = await response.text();
63
+ let data: unknown;
64
+ try {
65
+ data = JSON.parse(text);
66
+ } catch {
67
+ data = text;
68
+ }
69
+ return { ok: response.ok, status: response.status, data, text };
70
+ }
71
+
72
+ function ok(text: string) {
73
+ return { content: [{ type: "text" as const, text }] };
74
+ }
75
+
76
+ function err(text: string) {
77
+ return { content: [{ type: "text" as const, text }], isError: true as const };
78
+ }
79
+
80
+ function noConfig() {
81
+ return err("YouTrack not configured. Set YOUTRACK_BASE_URL and YOUTRACK_TOKEN in environment or .env file.");
82
+ }
83
+
84
+ // ── Formatters ──────────────────────────────────────────────────────────────
85
+
86
+ function formatTimestamp(ms: number | null | undefined): string {
87
+ if (!ms) return "—";
88
+ return new Date(ms).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
89
+ }
90
+
91
+ function formatCustomFields(fields: Array<{ name?: string; value?: unknown }>): string {
92
+ if (!fields || fields.length === 0) return "";
93
+ const lines: string[] = [];
94
+ for (const f of fields) {
95
+ const name = f.name ?? "unknown";
96
+ const val = f.value;
97
+ if (val === null || val === undefined) {
98
+ lines.push(` ${name}: —`);
99
+ } else if (Array.isArray(val)) {
100
+ const names = val.map((v: { name?: string; login?: string }) => v.name || v.login || "?").join(", ");
101
+ lines.push(` ${name}: ${names}`);
102
+ } else if (typeof val === "object" && val !== null) {
103
+ const v = val as { name?: string; login?: string; fullName?: string; minutes?: number };
104
+ lines.push(` ${name}: ${v.name || v.fullName || v.login || JSON.stringify(val)}`);
105
+ } else {
106
+ lines.push(` ${name}: ${String(val)}`);
107
+ }
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
112
+ function formatIssue(issue: Record<string, unknown>): string {
113
+ const lines: string[] = [];
114
+ const id = issue.idReadable || issue.id || "?";
115
+ const summary = issue.summary || "(no summary)";
116
+ lines.push(`${id}: ${summary}`);
117
+ if (issue.description) lines.push(`Description: ${String(issue.description).slice(0, 500)}`);
118
+ if (issue.project) {
119
+ const p = issue.project as { name?: string; shortName?: string };
120
+ lines.push(`Project: ${p.name || p.shortName || "?"}`);
121
+ }
122
+ if (issue.reporter) {
123
+ const r = issue.reporter as { login?: string; fullName?: string };
124
+ lines.push(`Reporter: ${r.fullName || r.login || "?"}`);
125
+ }
126
+ const created = issue.created as number | undefined;
127
+ const updated = issue.updated as number | undefined;
128
+ const resolved = issue.resolved as number | undefined;
129
+ if (created) lines.push(`Created: ${formatTimestamp(created)}`);
130
+ if (updated) lines.push(`Updated: ${formatTimestamp(updated)}`);
131
+ if (resolved) lines.push(`Resolved: ${formatTimestamp(resolved)}`);
132
+ if (issue.tags) {
133
+ const tags = issue.tags as Array<{ name?: string }>;
134
+ if (tags.length > 0) lines.push(`Tags: ${tags.map((t) => t.name || "?").join(", ")}`);
135
+ }
136
+ if (issue.votes !== undefined) lines.push(`Votes: ${issue.votes}`);
137
+ const cf = issue.customFields as Array<{ name?: string; value?: unknown }> | undefined;
138
+ if (cf && cf.length > 0) {
139
+ lines.push("Custom Fields:");
140
+ lines.push(formatCustomFields(cf));
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+
145
+ function formatIssueList(issues: Array<Record<string, unknown>>): string {
146
+ if (issues.length === 0) return "No issues found.";
147
+ const lines: string[] = [`${issues.length} issue(s):\n`];
148
+ for (const issue of issues) {
149
+ const id = issue.idReadable || issue.id || "?";
150
+ const summary = issue.summary || "(no summary)";
151
+ const cf = issue.customFields as Array<{ name?: string; value?: unknown }> | undefined;
152
+ let state = "";
153
+ let assignee = "";
154
+ let priority = "";
155
+ if (cf) {
156
+ for (const f of cf) {
157
+ const val = f.value as { name?: string; login?: string; fullName?: string } | null;
158
+ if (!val) continue;
159
+ if (f.name === "State") state = val.name || "";
160
+ if (f.name === "Assignee") assignee = val.fullName || val.login || val.name || "";
161
+ if (f.name === "Priority") priority = val.name || "";
162
+ }
163
+ }
164
+ const parts = [String(id), String(summary)];
165
+ if (state) parts.push(`[${state}]`);
166
+ if (priority) parts.push(`P:${priority}`);
167
+ if (assignee) parts.push(`@${assignee}`);
168
+ const resolved = issue.resolved as number | null | undefined;
169
+ if (resolved) parts.push("(resolved)");
170
+ lines.push(parts.join(" "));
171
+ }
172
+ return lines.join("\n");
173
+ }
174
+
175
+ // ── Extension ───────────────────────────────────────────────────────────────
176
+
177
+ export default function (elyra: ExtensionAPI): void {
178
+ let cwd = "";
179
+
180
+ elyra.on("session_start", async (_event, ctx) => {
181
+ cwd = ctx.cwd;
182
+ });
183
+
184
+ const getCwd = () => cwd;
185
+
186
+ const ISSUE_FIELDS = "id,idReadable,summary,description,project(id,name,shortName),reporter(login,fullName),created,updated,resolved,tags(id,name),votes,customFields(id,name,projectCustomField(field(name)),value(id,name,login,fullName,minutes))";
187
+ const ISSUE_LIST_FIELDS = "id,idReadable,summary,resolved,customFields(name,value(name,login,fullName))";
188
+
189
+ // ── youtrack_search_issues ───────────────────────────────────────────
190
+
191
+ elyra.registerTool({
192
+ name: "youtrack_search_issues",
193
+ label: "YouTrack Search",
194
+ description:
195
+ "Search for issues using YouTrack query language. Supports filters like 'for: me', '#Unresolved', 'project: {Name}', 'Priority: Critical', 'Type: Bug', and free text. Returns issue ID, summary, state, assignee, and priority.",
196
+ parameters: Type.Object({
197
+ query: Type.String({ description: "YouTrack search query (e.g. 'for: me #Unresolved', 'project: Backend Type: Bug')" }),
198
+ limit: Type.Optional(Type.Integer({ description: "Max results to return. Default 25, max 100." })),
199
+ offset: Type.Optional(Type.Integer({ description: "Number of results to skip for pagination." })),
200
+ }),
201
+ promptSnippet: "Search YouTrack issues",
202
+ async execute(_id, params) {
203
+ const config = getConfig(getCwd());
204
+ if (!config) return noConfig();
205
+ const top = Math.min(params.limit ?? 25, 100);
206
+ const skip = params.offset ?? 0;
207
+ const q = encodeURIComponent(params.query);
208
+ const res = await ytFetch(config, "GET", `/api/issues?fields=${ISSUE_LIST_FIELDS}&query=${q}&$top=${top}&$skip=${skip}`);
209
+ if (!res.ok) return err(`Search failed (${res.status}): ${res.text}`);
210
+ return ok(formatIssueList(res.data as Array<Record<string, unknown>>));
211
+ },
212
+ });
213
+
214
+ // ── youtrack_get_issue ───────────────────────────────────────────────
215
+
216
+ elyra.registerTool({
217
+ name: "youtrack_get_issue",
218
+ label: "YouTrack Get Issue",
219
+ description:
220
+ "Get full details for a specific issue including summary, description, custom fields, reporter, tags, and votes. Use the readable issue ID (e.g. 'PROJ-123').",
221
+ parameters: Type.Object({
222
+ issue_id: Type.String({ description: "Issue ID (e.g. 'PROJ-123')" }),
223
+ }),
224
+ promptSnippet: "Get YouTrack issue details",
225
+ async execute(_id, params) {
226
+ const config = getConfig(getCwd());
227
+ if (!config) return noConfig();
228
+ const res = await ytFetch(config, "GET", `/api/issues/${encodeURIComponent(params.issue_id)}?fields=${ISSUE_FIELDS}`);
229
+ if (!res.ok) return err(`Failed to get issue (${res.status}): ${res.text}`);
230
+ return ok(formatIssue(res.data as Record<string, unknown>));
231
+ },
232
+ });
233
+
234
+ // ── youtrack_create_issue ────────────────────────────────────────────
235
+
236
+ elyra.registerTool({
237
+ name: "youtrack_create_issue",
238
+ label: "YouTrack Create Issue",
239
+ description:
240
+ "Create a new issue in a project. Requires project ID and summary. Use youtrack_get_issue_fields_schema first to discover required custom fields and valid values.",
241
+ parameters: Type.Object({
242
+ project_id: Type.String({ description: "Project database ID (e.g. '0-3'). Use youtrack_find_projects to discover IDs." }),
243
+ summary: Type.String({ description: "Issue summary/title" }),
244
+ description: Type.Optional(Type.String({ description: "Issue description (supports Markdown)" })),
245
+ custom_fields: Type.Optional(Type.Array(
246
+ Type.Object({
247
+ name: Type.String({ description: "Custom field name (e.g. 'Priority', 'Type', 'State')" }),
248
+ value: Type.Unknown({ description: "Field value — object like {name: 'Critical'} or array for multi-value fields" }),
249
+ $type: Type.Optional(Type.String({ description: "Field type (e.g. 'SingleEnumIssueCustomField'). Get from schema." })),
250
+ }),
251
+ { description: "Custom field values. Use youtrack_get_issue_fields_schema to discover valid fields and values." },
252
+ )),
253
+ }),
254
+ promptSnippet: "Create a YouTrack issue",
255
+ async execute(_id, params) {
256
+ const config = getConfig(getCwd());
257
+ if (!config) return noConfig();
258
+ const body: Record<string, unknown> = {
259
+ summary: params.summary,
260
+ project: { id: params.project_id },
261
+ };
262
+ if (params.description) body.description = params.description;
263
+ if (params.custom_fields) body.customFields = params.custom_fields;
264
+ const res = await ytFetch(config, "POST", `/api/issues?fields=idReadable,id`, body);
265
+ if (!res.ok) return err(`Failed to create issue (${res.status}): ${res.text}`);
266
+ const data = res.data as { idReadable?: string; id?: string };
267
+ return ok(`Created issue ${data.idReadable || data.id}\nURL: ${config.baseUrl}/issue/${data.idReadable || data.id}`);
268
+ },
269
+ });
270
+
271
+ // ── youtrack_update_issue ────────────────────────────────────────────
272
+
273
+ elyra.registerTool({
274
+ name: "youtrack_update_issue",
275
+ label: "YouTrack Update Issue",
276
+ description:
277
+ "Update an existing issue. Can change summary, description, and custom fields (state, priority, type, etc). Use youtrack_get_issue_fields_schema to discover valid field values.",
278
+ parameters: Type.Object({
279
+ issue_id: Type.String({ description: "Issue ID (e.g. 'PROJ-123')" }),
280
+ summary: Type.Optional(Type.String({ description: "New summary" })),
281
+ description: Type.Optional(Type.String({ description: "New description" })),
282
+ custom_fields: Type.Optional(Type.Array(
283
+ Type.Object({
284
+ name: Type.String({ description: "Custom field name" }),
285
+ value: Type.Unknown({ description: "New value" }),
286
+ $type: Type.Optional(Type.String({ description: "Field type" })),
287
+ }),
288
+ )),
289
+ }),
290
+ promptSnippet: "Update a YouTrack issue",
291
+ async execute(_id, params) {
292
+ const config = getConfig(getCwd());
293
+ if (!config) return noConfig();
294
+ const body: Record<string, unknown> = {};
295
+ if (params.summary) body.summary = params.summary;
296
+ if (params.description !== undefined) body.description = params.description;
297
+ if (params.custom_fields) body.customFields = params.custom_fields;
298
+ const res = await ytFetch(config, "POST", `/api/issues/${encodeURIComponent(params.issue_id)}?fields=idReadable,id`, body);
299
+ if (!res.ok) return err(`Failed to update issue (${res.status}): ${res.text}`);
300
+ return ok(`Updated issue ${params.issue_id}`);
301
+ },
302
+ });
303
+
304
+ // ── youtrack_change_assignee ─────────────────────────────────────────
305
+
306
+ elyra.registerTool({
307
+ name: "youtrack_change_assignee",
308
+ label: "YouTrack Change Assignee",
309
+ description:
310
+ "Change the assignee of an issue. Provide the issue ID and the new assignee's login username.",
311
+ parameters: Type.Object({
312
+ issue_id: Type.String({ description: "Issue ID (e.g. 'PROJ-123')" }),
313
+ assignee: Type.String({ description: "Username (login) of the new assignee" }),
314
+ }),
315
+ promptSnippet: "Change YouTrack issue assignee",
316
+ async execute(_id, params) {
317
+ const config = getConfig(getCwd());
318
+ if (!config) return noConfig();
319
+ const body = {
320
+ customFields: [{
321
+ name: "Assignee",
322
+ $type: "SingleUserIssueCustomField",
323
+ value: { login: params.assignee },
324
+ }],
325
+ };
326
+ const res = await ytFetch(config, "POST", `/api/issues/${encodeURIComponent(params.issue_id)}?fields=idReadable`, body);
327
+ if (!res.ok) return err(`Failed to change assignee (${res.status}): ${res.text}`);
328
+ return ok(`Assigned ${params.issue_id} to @${params.assignee}`);
329
+ },
330
+ });
331
+
332
+ // ── youtrack_add_comment ─────────────────────────────────────────────
333
+
334
+ elyra.registerTool({
335
+ name: "youtrack_add_comment",
336
+ label: "YouTrack Add Comment",
337
+ description:
338
+ "Add a comment to an issue. Supports Markdown formatting.",
339
+ parameters: Type.Object({
340
+ issue_id: Type.String({ description: "Issue ID (e.g. 'PROJ-123')" }),
341
+ text: Type.String({ description: "Comment text (supports Markdown)" }),
342
+ }),
343
+ promptSnippet: "Add a comment to a YouTrack issue",
344
+ async execute(_id, params) {
345
+ const config = getConfig(getCwd());
346
+ if (!config) return noConfig();
347
+ const res = await ytFetch(config, "POST", `/api/issues/${encodeURIComponent(params.issue_id)}/comments?fields=id,created`, { text: params.text });
348
+ if (!res.ok) return err(`Failed to add comment (${res.status}): ${res.text}`);
349
+ return ok(`Comment added to ${params.issue_id}`);
350
+ },
351
+ });
352
+
353
+ // ── youtrack_get_comments ────────────────────────────────────────────
354
+
355
+ elyra.registerTool({
356
+ name: "youtrack_get_comments",
357
+ label: "YouTrack Get Comments",
358
+ description:
359
+ "Get all comments for an issue, including author, timestamp, and text.",
360
+ parameters: Type.Object({
361
+ issue_id: Type.String({ description: "Issue ID (e.g. 'PROJ-123')" }),
362
+ limit: Type.Optional(Type.Integer({ description: "Max comments. Default 25." })),
363
+ offset: Type.Optional(Type.Integer({ description: "Skip N comments for pagination." })),
364
+ }),
365
+ promptSnippet: "Get comments for a YouTrack issue",
366
+ async execute(_id, params) {
367
+ const config = getConfig(getCwd());
368
+ if (!config) return noConfig();
369
+ const top = Math.min(params.limit ?? 25, 100);
370
+ const skip = params.offset ?? 0;
371
+ const res = await ytFetch(config, "GET", `/api/issues/${encodeURIComponent(params.issue_id)}/comments?fields=id,text,created,author(login,fullName)&$top=${top}&$skip=${skip}`);
372
+ if (!res.ok) return err(`Failed to get comments (${res.status}): ${res.text}`);
373
+ const comments = res.data as Array<{ id?: string; text?: string; created?: number; author?: { login?: string; fullName?: string } }>;
374
+ if (comments.length === 0) return ok("No comments on this issue.");
375
+ const lines: string[] = [`${comments.length} comment(s):\n`];
376
+ for (const c of comments) {
377
+ const author = c.author?.fullName || c.author?.login || "?";
378
+ lines.push(`[${formatTimestamp(c.created)}] ${author}:`);
379
+ lines.push(c.text || "(empty)");
380
+ lines.push("");
381
+ }
382
+ return ok(lines.join("\n"));
383
+ },
384
+ });
385
+
386
+ // ── youtrack_manage_tags ─────────────────────────────────────────────
387
+
388
+ elyra.registerTool({
389
+ name: "youtrack_manage_tags",
390
+ label: "YouTrack Manage Tags",
391
+ description:
392
+ "Add or remove a tag from an issue. If adding by name, the first matching tag is used.",
393
+ parameters: Type.Object({
394
+ issue_id: Type.String({ description: "Issue ID (e.g. 'PROJ-123')" }),
395
+ tag: Type.String({ description: "Tag name or ID" }),
396
+ action: Type.Union([Type.Literal("add"), Type.Literal("remove")], { description: "'add' or 'remove'" }),
397
+ }),
398
+ promptSnippet: "Add or remove a tag on a YouTrack issue",
399
+ async execute(_id, params) {
400
+ const config = getConfig(getCwd());
401
+ if (!config) return noConfig();
402
+ if (params.action === "add") {
403
+ const res = await ytFetch(config, "POST", `/api/issues/${encodeURIComponent(params.issue_id)}/tags?fields=id,name`, { name: params.tag });
404
+ if (!res.ok) return err(`Failed to add tag (${res.status}): ${res.text}`);
405
+ return ok(`Added tag "${params.tag}" to ${params.issue_id}`);
406
+ }
407
+ // Remove: first find the tag ID
408
+ const tagsRes = await ytFetch(config, "GET", `/api/issues/${encodeURIComponent(params.issue_id)}?fields=tags(id,name)`);
409
+ if (!tagsRes.ok) return err(`Failed to get tags (${tagsRes.status}): ${tagsRes.text}`);
410
+ const issue = tagsRes.data as { tags?: Array<{ id: string; name: string }> };
411
+ const tag = issue.tags?.find((t) => t.name.toLowerCase() === params.tag.toLowerCase() || t.id === params.tag);
412
+ if (!tag) return err(`Tag "${params.tag}" not found on ${params.issue_id}`);
413
+ const res = await ytFetch(config, "DELETE", `/api/issues/${encodeURIComponent(params.issue_id)}/tags/${tag.id}`);
414
+ if (!res.ok) return err(`Failed to remove tag (${res.status}): ${res.text}`);
415
+ return ok(`Removed tag "${params.tag}" from ${params.issue_id}`);
416
+ },
417
+ });
418
+
419
+ // ── youtrack_link_issues ─────────────────────────────────────────────
420
+
421
+ elyra.registerTool({
422
+ name: "youtrack_link_issues",
423
+ label: "YouTrack Link Issues",
424
+ description:
425
+ "Link two issues with a specified link type (e.g. 'relates to', 'depends on', 'parent for', 'subtask of', 'duplicates').",
426
+ parameters: Type.Object({
427
+ issue_id: Type.String({ description: "Source issue ID (e.g. 'PROJ-123')" }),
428
+ target_issue_id: Type.String({ description: "Target issue ID to link to" }),
429
+ link_type: Type.String({ description: "Link type name (e.g. 'relates to', 'depends on', 'parent for')" }),
430
+ }),
431
+ promptSnippet: "Link two YouTrack issues",
432
+ async execute(_id, params) {
433
+ const config = getConfig(getCwd());
434
+ if (!config) return noConfig();
435
+ const body = {
436
+ issues: [{ idReadable: params.target_issue_id }],
437
+ linkType: { name: params.link_type },
438
+ };
439
+ const res = await ytFetch(config, "POST", `/api/issues/${encodeURIComponent(params.issue_id)}/links?fields=id`, body);
440
+ if (!res.ok) return err(`Failed to link issues (${res.status}): ${res.text}`);
441
+ return ok(`Linked ${params.issue_id} → ${params.target_issue_id} (${params.link_type})`);
442
+ },
443
+ });
444
+
445
+ // ── youtrack_find_projects ───────────────────────────────────────────
446
+
447
+ elyra.registerTool({
448
+ name: "youtrack_find_projects",
449
+ label: "YouTrack Find Projects",
450
+ description:
451
+ "Search for projects by name. Returns project ID, name, and short name.",
452
+ parameters: Type.Object({
453
+ query: Type.Optional(Type.String({ description: "Project name substring to search for (case-insensitive). Returns all projects if omitted." })),
454
+ limit: Type.Optional(Type.Integer({ description: "Max results. Default 25." })),
455
+ }),
456
+ promptSnippet: "Find YouTrack projects",
457
+ async execute(_id, params) {
458
+ const config = getConfig(getCwd());
459
+ if (!config) return noConfig();
460
+ const top = Math.min(params.limit ?? 25, 100);
461
+ let path = `/api/admin/projects?fields=id,name,shortName&$top=${top}`;
462
+ if (params.query) path += `&query=${encodeURIComponent(params.query)}`;
463
+ const res = await ytFetch(config, "GET", path);
464
+ if (!res.ok) return err(`Failed to find projects (${res.status}): ${res.text}`);
465
+ const projects = res.data as Array<{ id: string; name: string; shortName: string }>;
466
+ if (projects.length === 0) return ok("No projects found.");
467
+ const lines = projects.map((p) => `${p.shortName} (${p.id}): ${p.name}`);
468
+ return ok(`${projects.length} project(s):\n${lines.join("\n")}`);
469
+ },
470
+ });
471
+
472
+ // ── youtrack_get_project ─────────────────────────────────────────────
473
+
474
+ elyra.registerTool({
475
+ name: "youtrack_get_project",
476
+ label: "YouTrack Get Project",
477
+ description:
478
+ "Get full details for a project including name, description, leader, and creation date.",
479
+ parameters: Type.Object({
480
+ project_id: Type.String({ description: "Project ID (database ID like '0-3' or short name like 'PROJ')" }),
481
+ }),
482
+ promptSnippet: "Get YouTrack project details",
483
+ async execute(_id, params) {
484
+ const config = getConfig(getCwd());
485
+ if (!config) return noConfig();
486
+ const res = await ytFetch(config, "GET", `/api/admin/projects/${encodeURIComponent(params.project_id)}?fields=id,name,shortName,description,leader(login,fullName),createdBy(login,fullName),archived,fromEmail,replyToEmail`);
487
+ if (!res.ok) return err(`Failed to get project (${res.status}): ${res.text}`);
488
+ const p = res.data as Record<string, unknown>;
489
+ const lines: string[] = [];
490
+ lines.push(`${p.shortName} (${p.id}): ${p.name}`);
491
+ if (p.description) lines.push(`Description: ${p.description}`);
492
+ if (p.leader) {
493
+ const l = p.leader as { login?: string; fullName?: string };
494
+ lines.push(`Leader: ${l.fullName || l.login || "?"}`);
495
+ }
496
+ if (p.archived) lines.push("Status: Archived");
497
+ return ok(lines.join("\n"));
498
+ },
499
+ });
500
+
501
+ // ── youtrack_get_issue_fields_schema ─────────────────────────────────
502
+
503
+ elyra.registerTool({
504
+ name: "youtrack_get_issue_fields_schema",
505
+ label: "YouTrack Fields Schema",
506
+ description:
507
+ "Get the custom field schema for a project — field names, types, required status, and possible values. Essential before creating or updating issues.",
508
+ parameters: Type.Object({
509
+ project_id: Type.String({ description: "Project ID (database ID like '0-3' or short name like 'PROJ')" }),
510
+ }),
511
+ promptSnippet: "Get YouTrack custom field schema for a project",
512
+ async execute(_id, params) {
513
+ const config = getConfig(getCwd());
514
+ if (!config) return noConfig();
515
+ const res = await ytFetch(config, "GET", `/api/admin/projects/${encodeURIComponent(params.project_id)}/customFields?fields=id,field(name,fieldType(id)),canBeEmpty,emptyFieldText,bundle(id,values(name,login,fullName))`);
516
+ if (!res.ok) return err(`Failed to get schema (${res.status}): ${res.text}`);
517
+ const fields = res.data as Array<{
518
+ id: string;
519
+ field: { name: string; fieldType?: { id?: string } };
520
+ canBeEmpty?: boolean;
521
+ emptyFieldText?: string;
522
+ bundle?: { values?: Array<{ name?: string; login?: string; fullName?: string }> };
523
+ }>;
524
+ if (fields.length === 0) return ok("No custom fields configured for this project.");
525
+ const lines: string[] = [`${fields.length} custom field(s):\n`];
526
+ for (const f of fields) {
527
+ const required = f.canBeEmpty === false ? " (required)" : "";
528
+ const type = f.field.fieldType?.id || "unknown";
529
+ lines.push(`${f.field.name} [${type}]${required}`);
530
+ if (f.bundle?.values && f.bundle.values.length > 0) {
531
+ const vals = f.bundle.values.map((v) => v.name || v.fullName || v.login || "?");
532
+ lines.push(` Values: ${vals.join(", ")}`);
533
+ }
534
+ }
535
+ return ok(lines.join("\n"));
536
+ },
537
+ });
538
+
539
+ // ── youtrack_find_user ───────────────────────────────────────────────
540
+
541
+ elyra.registerTool({
542
+ name: "youtrack_find_user",
543
+ label: "YouTrack Find User",
544
+ description:
545
+ "Find a user by username, full name, or email. Returns profile data.",
546
+ parameters: Type.Object({
547
+ query: Type.String({ description: "Username, full name, or email to search for" }),
548
+ }),
549
+ promptSnippet: "Find a YouTrack user",
550
+ async execute(_id, params) {
551
+ const config = getConfig(getCwd());
552
+ if (!config) return noConfig();
553
+ const res = await ytFetch(config, "GET", `/api/users?fields=id,login,fullName,email,banned&query=${encodeURIComponent(params.query)}&$top=10`);
554
+ if (!res.ok) return err(`Failed to find user (${res.status}): ${res.text}`);
555
+ const users = res.data as Array<{ id: string; login: string; fullName?: string; email?: string; banned?: boolean }>;
556
+ if (users.length === 0) return ok("No users found.");
557
+ const lines = users.map((u) => {
558
+ const parts = [`@${u.login}`];
559
+ if (u.fullName) parts.push(`(${u.fullName})`);
560
+ if (u.email) parts.push(`<${u.email}>`);
561
+ if (u.banned) parts.push("[banned]");
562
+ return parts.join(" ");
563
+ });
564
+ return ok(`${users.length} user(s):\n${lines.join("\n")}`);
565
+ },
566
+ });
567
+
568
+ // ── youtrack_get_current_user ────────────────────────────────────────
569
+
570
+ elyra.registerTool({
571
+ name: "youtrack_get_current_user",
572
+ label: "YouTrack Current User",
573
+ description:
574
+ "Get details about the currently authenticated user — username, email, full name.",
575
+ parameters: Type.Object({}),
576
+ promptSnippet: "Get current YouTrack user info",
577
+ async execute() {
578
+ const config = getConfig(getCwd());
579
+ if (!config) return noConfig();
580
+ const res = await ytFetch(config, "GET", "/api/users/me?fields=id,login,fullName,email,avatarUrl");
581
+ if (!res.ok) return err(`Failed to get user (${res.status}): ${res.text}`);
582
+ const u = res.data as { login?: string; fullName?: string; email?: string };
583
+ const lines: string[] = [];
584
+ if (u.fullName) lines.push(`Name: ${u.fullName}`);
585
+ if (u.login) lines.push(`Login: @${u.login}`);
586
+ if (u.email) lines.push(`Email: ${u.email}`);
587
+ return ok(lines.join("\n"));
588
+ },
589
+ });
590
+
591
+ // ── youtrack_search_articles ─────────────────────────────────────────
592
+
593
+ elyra.registerTool({
594
+ name: "youtrack_search_articles",
595
+ label: "YouTrack Search Articles",
596
+ description:
597
+ "Search for knowledge base articles using YouTrack query language.",
598
+ parameters: Type.Object({
599
+ query: Type.String({ description: "Search query for articles" }),
600
+ limit: Type.Optional(Type.Integer({ description: "Max results. Default 25." })),
601
+ offset: Type.Optional(Type.Integer({ description: "Skip N results." })),
602
+ }),
603
+ promptSnippet: "Search YouTrack knowledge base articles",
604
+ async execute(_id, params) {
605
+ const config = getConfig(getCwd());
606
+ if (!config) return noConfig();
607
+ const top = Math.min(params.limit ?? 25, 100);
608
+ const skip = params.offset ?? 0;
609
+ const q = encodeURIComponent(params.query);
610
+ const res = await ytFetch(config, "GET", `/api/articles?fields=id,idReadable,summary,project(name)&query=${q}&$top=${top}&$skip=${skip}`);
611
+ if (!res.ok) return err(`Failed to search articles (${res.status}): ${res.text}`);
612
+ const articles = res.data as Array<{ id: string; idReadable?: string; summary?: string; project?: { name?: string } }>;
613
+ if (articles.length === 0) return ok("No articles found.");
614
+ const lines = articles.map((a) => `${a.idReadable || a.id}: ${a.summary || "(no title)"}${a.project?.name ? ` [${a.project.name}]` : ""}`);
615
+ return ok(`${articles.length} article(s):\n${lines.join("\n")}`);
616
+ },
617
+ });
618
+
619
+ // ── youtrack_get_article ─────────────────────────────────────────────
620
+
621
+ elyra.registerTool({
622
+ name: "youtrack_get_article",
623
+ label: "YouTrack Get Article",
624
+ description:
625
+ "Get full content of a knowledge base article including title, content, and sub-articles.",
626
+ parameters: Type.Object({
627
+ article_id: Type.String({ description: "Article ID (e.g. 'PROJ-A-1')" }),
628
+ }),
629
+ promptSnippet: "Get a YouTrack knowledge base article",
630
+ async execute(_id, params) {
631
+ const config = getConfig(getCwd());
632
+ if (!config) return noConfig();
633
+ const res = await ytFetch(config, "GET", `/api/articles/${encodeURIComponent(params.article_id)}?fields=id,idReadable,summary,content,project(name),reporter(login,fullName),created,updated,childArticles(id,idReadable,summary)`);
634
+ if (!res.ok) return err(`Failed to get article (${res.status}): ${res.text}`);
635
+ const a = res.data as Record<string, unknown>;
636
+ const lines: string[] = [];
637
+ lines.push(`# ${a.summary || "(no title)"}`);
638
+ if (a.project) lines.push(`Project: ${(a.project as { name?: string }).name || "?"}`);
639
+ if (a.reporter) {
640
+ const r = a.reporter as { login?: string; fullName?: string };
641
+ lines.push(`Author: ${r.fullName || r.login || "?"}`);
642
+ }
643
+ if (a.created) lines.push(`Created: ${formatTimestamp(a.created as number)}`);
644
+ if (a.updated) lines.push(`Updated: ${formatTimestamp(a.updated as number)}`);
645
+ lines.push("");
646
+ if (a.content) lines.push(String(a.content));
647
+ const children = a.childArticles as Array<{ idReadable?: string; summary?: string }> | undefined;
648
+ if (children && children.length > 0) {
649
+ lines.push("\nSub-articles:");
650
+ for (const c of children) {
651
+ lines.push(` ${c.idReadable}: ${c.summary || "(no title)"}`);
652
+ }
653
+ }
654
+ return ok(lines.join("\n"));
655
+ },
656
+ });
657
+
658
+ // ── youtrack_create_article ──────────────────────────────────────────
659
+
660
+ elyra.registerTool({
661
+ name: "youtrack_create_article",
662
+ label: "YouTrack Create Article",
663
+ description:
664
+ "Create a new knowledge base article in a project.",
665
+ parameters: Type.Object({
666
+ project_id: Type.String({ description: "Project ID" }),
667
+ summary: Type.String({ description: "Article title" }),
668
+ content: Type.String({ description: "Article content (supports Markdown)" }),
669
+ parent_article_id: Type.Optional(Type.String({ description: "Parent article ID to nest under" })),
670
+ }),
671
+ promptSnippet: "Create a YouTrack knowledge base article",
672
+ async execute(_id, params) {
673
+ const config = getConfig(getCwd());
674
+ if (!config) return noConfig();
675
+ const body: Record<string, unknown> = {
676
+ summary: params.summary,
677
+ content: params.content,
678
+ project: { id: params.project_id },
679
+ };
680
+ if (params.parent_article_id) body.parentArticle = { id: params.parent_article_id };
681
+ const res = await ytFetch(config, "POST", "/api/articles?fields=id,idReadable", body);
682
+ if (!res.ok) return err(`Failed to create article (${res.status}): ${res.text}`);
683
+ const data = res.data as { idReadable?: string; id?: string };
684
+ return ok(`Created article ${data.idReadable || data.id}\nURL: ${config.baseUrl}/articles/${data.idReadable || data.id}`);
685
+ },
686
+ });
687
+
688
+ // ── youtrack_log_work ────────────────────────────────────────────────
689
+
690
+ elyra.registerTool({
691
+ name: "youtrack_log_work",
692
+ label: "YouTrack Log Work",
693
+ description:
694
+ "Log time spent on an issue. Specify duration in minutes.",
695
+ parameters: Type.Object({
696
+ issue_id: Type.String({ description: "Issue ID (e.g. 'PROJ-123')" }),
697
+ minutes: Type.Integer({ description: "Duration in minutes" }),
698
+ description: Type.Optional(Type.String({ description: "Work item description" })),
699
+ date: Type.Optional(Type.String({ description: "Date (ISO 8601, e.g. '2026-05-27'). Defaults to today." })),
700
+ work_type: Type.Optional(Type.String({ description: "Work type name (e.g. 'Development', 'Testing'). Use youtrack_get_project to see available work types." })),
701
+ }),
702
+ promptSnippet: "Log work time on a YouTrack issue",
703
+ async execute(_id, params) {
704
+ const config = getConfig(getCwd());
705
+ if (!config) return noConfig();
706
+ const body: Record<string, unknown> = {
707
+ duration: { minutes: params.minutes },
708
+ };
709
+ if (params.description) body.text = params.description;
710
+ if (params.date) body.date = new Date(params.date).getTime();
711
+ if (params.work_type) body.type = { name: params.work_type };
712
+ const res = await ytFetch(config, "POST", `/api/issues/${encodeURIComponent(params.issue_id)}/timeTracking/workItems?fields=id`, body);
713
+ if (!res.ok) return err(`Failed to log work (${res.status}): ${res.text}`);
714
+ return ok(`Logged ${params.minutes} minutes on ${params.issue_id}`);
715
+ },
716
+ });
717
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@elyracode/youtrack",
3
+ "version": "0.8.1",
4
+ "description": "YouTrack integration for Elyra -- search issues, manage assignments, comments, projects, and more",
5
+ "type": "module",
6
+ "keywords": [
7
+ "elyra-package",
8
+ "youtrack",
9
+ "jetbrains",
10
+ "issue-tracker",
11
+ "project-management"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Knut W. Horne",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/kwhorne/elyra.git",
18
+ "directory": "packages/youtrack"
19
+ },
20
+ "elyra": {
21
+ "extensions": [
22
+ "./extensions/index.ts"
23
+ ],
24
+ "skills": [
25
+ "./skills"
26
+ ]
27
+ },
28
+ "peerDependencies": {
29
+ "@elyracode/coding-agent": "*",
30
+ "typebox": "*"
31
+ },
32
+ "scripts": {
33
+ "clean": "echo 'nothing to clean'",
34
+ "build": "echo 'nothing to build'",
35
+ "check": "echo 'nothing to check'"
36
+ }
37
+ }
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: elyra-youtrack
3
+ description: YouTrack issue tracker integration. Use when the user asks about issues, bugs, tasks, sprints, projects, time tracking, or anything related to their YouTrack instance.
4
+ ---
5
+
6
+ # YouTrack Integration
7
+
8
+ ## When to Use
9
+
10
+ Use YouTrack tools when:
11
+ - The user asks about issues, bugs, tasks, or tickets
12
+ - The user wants to search, create, update, or comment on issues
13
+ - The user asks about project status, sprints, or backlogs
14
+ - The user needs to look up knowledge base articles
15
+ - The user wants to log or review time spent on issues
16
+ - The user references YouTrack issue IDs (e.g. `PROJ-123`)
17
+
18
+ ## Available Tools
19
+
20
+ | Tool | Use when |
21
+ |------|----------|
22
+ | `youtrack_search_issues` | Finding issues by query, status, assignee, project, etc. |
23
+ | `youtrack_get_issue` | Getting full details of a specific issue by ID |
24
+ | `youtrack_create_issue` | Creating a new issue (always get project schema first) |
25
+ | `youtrack_update_issue` | Changing fields like state, assignee, priority, summary |
26
+ | `youtrack_delete_issue` | Removing an issue permanently |
27
+ | `youtrack_list_comments` | Reading discussion on an issue |
28
+ | `youtrack_add_comment` | Posting a comment on an issue |
29
+ | `youtrack_list_tags` | Browsing available tags for filtering or applying |
30
+ | `youtrack_apply_tag` | Tagging or untagging an issue |
31
+ | `youtrack_list_links` | Viewing issue relationships (parent, subtask, duplicate, related) |
32
+ | `youtrack_add_link` | Linking two issues together |
33
+ | `youtrack_list_projects` | Listing all accessible projects |
34
+ | `youtrack_get_project` | Getting project details and custom field schema |
35
+ | `youtrack_list_users` | Finding users for assignment or mentions |
36
+ | `youtrack_get_current_user` | Getting the authenticated user's profile |
37
+ | `youtrack_search_articles` | Searching the knowledge base |
38
+ | `youtrack_get_article` | Reading a specific knowledge base article |
39
+ | `youtrack_add_work_item` | Logging time spent on an issue |
40
+
41
+ ## YouTrack Query Syntax
42
+
43
+ Use these query terms with `youtrack_search_issues`:
44
+
45
+ | Query | Meaning |
46
+ |-------|---------|
47
+ | `for: me` | Issues assigned to the current user |
48
+ | `by: me` | Issues reported by the current user |
49
+ | `#Unresolved` | Issues that are not yet resolved |
50
+ | `#Resolved` | Issues that have been resolved |
51
+ | `project: PROJ` | Issues in a specific project |
52
+ | `Priority: Critical` | Issues with a specific priority |
53
+ | `Type: Bug` | Issues of a specific type |
54
+ | `created: today` | Issues created today |
55
+ | `created: this week` | Issues created this week |
56
+ | `updated: yesterday .. today` | Issues updated in a date range |
57
+ | `sort by: created desc` | Sort by creation date, newest first |
58
+ | `sort by: updated asc` | Sort by update date, oldest first |
59
+ | `sort by: Priority desc` | Sort by priority, highest first |
60
+
61
+ Combine terms freely: `project: PROJ #Unresolved for: me sort by: Priority desc`
62
+
63
+ ## Issue Creation Workflow
64
+
65
+ When creating issues, always follow this sequence:
66
+
67
+ 1. **Get the project schema first** -- call `youtrack_get_project` to retrieve the project's custom fields, required fields, and allowed values.
68
+ 2. **Create the issue** -- call `youtrack_create_issue` with the correct field values based on the schema.
69
+
70
+ This prevents validation errors from incorrect or missing custom field values.
71
+
72
+ ## Common Query Patterns
73
+
74
+ | Goal | Query |
75
+ |------|-------|
76
+ | My open issues | `for: me #Unresolved` |
77
+ | My open bugs | `for: me #Unresolved Type: Bug` |
78
+ | Unresolved critical issues in a project | `project: PROJ #Unresolved Priority: Critical` |
79
+ | Issues I reported that are still open | `by: me #Unresolved` |
80
+ | Recently updated issues | `updated: today sort by: updated desc` |
81
+ | Issues created this week | `created: this week sort by: created desc` |
82
+ | All issues for a specific tag | `tag: backend` |
83
+ | Unresolved issues with no assignee | `#Unresolved has: -Assignee` |
84
+
85
+ ## Rules
86
+
87
+ - Always call `youtrack_get_project` before `youtrack_create_issue` to learn the field schema
88
+ - Use `youtrack_search_issues` to verify an issue exists before attempting updates
89
+ - When the user says "my issues", use `for: me` (assigned) unless they clarify they mean reported (`by: me`)
90
+ - Prefer searching by issue ID when the user provides one (e.g. `PROJ-123`) rather than a text search
91
+ - Add `#Unresolved` to queries unless the user explicitly asks for resolved or all issues