@stonepandastudio/cairn 0.2.1 → 0.3.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/README.md CHANGED
@@ -19,7 +19,7 @@ npx cairn init --stack backend --tracker jira-server --project-key MYPROJ --stor
19
19
 
20
20
  | Command | |
21
21
  |---|---|
22
- | `cairn tracker <cmd>` | Issue tracker client. Replaces the copied `ai/scripts/jira.js` files |
22
+ | `cairn tracker <cmd>` | Issue tracker client (`jira-server` / `youtrack` / `none`). Replaces the copied `ai/scripts/*.js` clients |
23
23
  | `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
24
24
  | `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
25
25
 
@@ -58,9 +58,12 @@ cairn tracker append-description MYPROJ-101 ./ai/tasks/MYPROJ-101/brief-step-1.m
58
58
  cairn tracker close-story MYPROJ-42 # cascades through subtasks first
59
59
  ```
60
60
 
61
- Providers are `jira-server` and `none`. `none` is a real provider, not an error
62
- case: a repo with no tracker makes the workflow steps no-ops rather than every
63
- command stub needing an "if this repo has a tracker" branch written in prose.
61
+ Providers are `jira-server`, `youtrack` and `none`, chosen per repo via
62
+ `tracker.provider`. `none` is a real provider, not an error case: a repo with no
63
+ tracker makes the workflow steps no-ops rather than every command stub needing an
64
+ "if this repo has a tracker" branch written in prose. YouTrack has no Task/Sub-task
65
+ type split (a `Subtask` link instead), a `Stage` state field rather than a
66
+ transition graph, and markdown-native descriptions.
64
67
 
65
68
  Three things the copied scripts did that this does not:
66
69
 
@@ -72,8 +75,10 @@ Three things the copied scripts did that this does not:
72
75
  explaining markdown → Jira wiki conversion. It is `toMarkup()` now, under test,
73
76
  reachable via `--markup`.
74
77
 
75
- Credentials come from the repo's `.env` (`JIRA_BASE_URL`, `JIRA_USER`, `JIRA_PASSWORD`).
76
- Jira Server 8.5.1 predates PAT support, so auth is Basic. Anything already in the
78
+ Credentials come from the repo's `.env` `JIRA_BASE_URL` / `JIRA_USER` /
79
+ `JIRA_PASSWORD` for `jira-server` (Basic auth: Jira Server 8.5.1 predates PATs), or
80
+ `YOUTRACK_URL` / `YOUTRACK_TOKEN` / `YOUTRACK_PROJECT` for `youtrack` (Bearer token,
81
+ admin-read scope for the project and stage-bundle lookups). Anything already in the
77
82
  environment wins over the file.
78
83
 
79
84
  ### Legacy command names
@@ -154,7 +159,7 @@ bin/cairn.js subcommand router
154
159
  lib/config.js cairn.config.json + .env loading
155
160
  lib/manifest.js generated-file hashing and state
156
161
  lib/paint.js ANSI + table rendering
157
- lib/tracker/ index (registry), cli, jira-server, none
162
+ lib/tracker/ index (registry), cli, jira-server, youtrack, none
158
163
  lib/doctor/ index (analysis + report), scan, normalize, diff
159
164
  lib/init.js cairn init
160
165
  templates/shims/ vendored shim source
@@ -162,4 +167,4 @@ schema.json JSON Schema for cairn.config.json
162
167
  test/run.js dependency-free test runner
163
168
  ```
164
169
 
165
- No runtime dependencies. Node >= 18. `npm test` runs 35 tests.
170
+ No runtime dependencies. Node >= 18. `npm test` runs 47 tests.
package/lib/init.js CHANGED
@@ -29,7 +29,17 @@ const { makePaint } = require('./paint');
29
29
 
30
30
  const SHIM_SOURCE = 'shims/jira.js';
31
31
  const SHIM_TARGET = '_cairn/scripts/jira.js';
32
- const LEGACY_SHIM_TARGET = 'ai/scripts/jira.js';
32
+
33
+ // Copied tracker-client filenames seen across the repos. `cairn init` replaces
34
+ // every one that exists with the shim — the snap-proof repos carry all three
35
+ // (`task-tracker.js` dispatching to `jira.js` / `youtrack.js`), and the docs
36
+ // that reference them by path keep working because the shim reads the same
37
+ // argv. A repo that never had one is left alone; the shim is never invented.
38
+ const LEGACY_SHIM_TARGETS = [
39
+ 'ai/scripts/jira.js',
40
+ 'ai/scripts/youtrack.js',
41
+ 'ai/scripts/task-tracker.js',
42
+ ];
33
43
 
34
44
  const HELP = `cairn init — make a repo cairn-managed
35
45
 
@@ -41,7 +51,7 @@ Usage: cairn init [options]
41
51
  --project-key <KEY> tracker project key, e.g. PROOF
42
52
  --story-type <name> parent issue type (default: Story)
43
53
  --subtask-type <name> child issue type (default: Sub-task)
44
- --no-shim do not vendor the ai/scripts/jira.js compatibility shim
54
+ --no-shim do not vendor or replace the ai/scripts/*.js client shims
45
55
  --force overwrite an existing cairn.config.json
46
56
  --dry-run print what would be written, write nothing
47
57
  `;
@@ -89,8 +99,11 @@ function buildConfig(args) {
89
99
  };
90
100
  if (args.tracker !== 'none') {
91
101
  cfg.tracker.projectKey = args.projectKey;
92
- cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
93
102
  cfg.tracker.env = '.env';
103
+ // issueTypes is a Jira concept — YouTrack has no Task/Sub-task split.
104
+ if (args.tracker === 'jira-server') {
105
+ cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
106
+ }
94
107
  }
95
108
  cfg.agents = {};
96
109
  return cfg;
@@ -137,10 +150,12 @@ function main(argv = process.argv.slice(2)) {
137
150
  const shim = readTemplate(SHIM_SOURCE);
138
151
  if (args.shim) {
139
152
  planned.push([SHIM_TARGET, shim, SHIM_SOURCE]);
140
- // Only replace the legacy path if a copied client is actually there. Creating
141
- // it in a repo that never had one would invent a path nothing references.
142
- if (fs.existsSync(path.join(repoPath, LEGACY_SHIM_TARGET))) {
143
- planned.push([LEGACY_SHIM_TARGET, shim, SHIM_SOURCE]);
153
+ // Only replace a legacy path if a copied client is actually there — creating
154
+ // one in a repo that never had it would invent a path nothing references.
155
+ for (const target of LEGACY_SHIM_TARGETS) {
156
+ if (fs.existsSync(path.join(repoPath, target))) {
157
+ planned.push([target, shim, SHIM_SOURCE]);
158
+ }
144
159
  }
145
160
  }
146
161
 
@@ -183,4 +198,4 @@ function main(argv = process.argv.slice(2)) {
183
198
  return 0;
184
199
  }
185
200
 
186
- module.exports = { main, buildConfig, HELP, SHIM_TARGET, LEGACY_SHIM_TARGET, cairnDir };
201
+ module.exports = { main, buildConfig, HELP, SHIM_TARGET, LEGACY_SHIM_TARGETS, cairnDir };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { loadEnv, loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
4
4
  const { createJiraServer, TrackerError, NoTransitionError } = require('./jira-server');
5
+ const { createYouTrack } = require('./youtrack');
5
6
  const { createNone } = require('./none');
6
7
 
7
8
  // Provider registry. Adding Linear or GitHub Issues later means adding one entry
@@ -9,6 +10,7 @@ const { createNone } = require('./none');
9
10
  // sees the shared interface.
10
11
  const PROVIDERS = {
11
12
  'jira-server': createJiraServer,
13
+ youtrack: createYouTrack,
12
14
  none: createNone,
13
15
  };
14
16
 
@@ -241,11 +241,35 @@ function createJiraServer(config = {}, env = process.env) {
241
241
  // Markdown -> Jira wiki markup. This used to be a per-repo prose document
242
242
  // (`ai/JIRA_MARKUP.md`, three variants) telling agents to convert by hand.
243
243
  // Conversion is deterministic, so it belongs in code where it can't drift.
244
+ // The conversion table it implements lives in those repos' `ai/JIRA_MARKUP.md`
245
+ // and `ai/TASK_TRACKER_MARKUP.md` — headings, bold/italic/strikethrough, inline
246
+ // and fenced code, links, blockquotes, nested lists, rules, and pipe tables.
247
+ function toMarkupInline(s) {
248
+ // Jira uses `*` for bold and `_` for italic. One pass handles `**b**` and
249
+ // `*i*`: group 1 captures which marker it was, the \s guards stop a bullet
250
+ // line ("* item") being read as emphasis, and the non-greedy body keeps
251
+ // `**b** and *i*` from matching as a single span.
252
+ s = s.replace(/(\*\*?)(?!\s)([^*\n]+?)(?<!\s)\1/g, (_, m, t) =>
253
+ m === '**' ? `*${t}*` : `_${t}_`,
254
+ );
255
+ s = s.replace(/~~([^~]+)~~/g, '-$1-');
256
+ s = s.replace(/`([^`]+)`/g, '{{$1}}');
257
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1|$2]');
258
+ return s;
259
+ }
260
+
244
261
  function toMarkup(markdown) {
245
262
  const lines = String(markdown).replace(/\r\n?/g, '\n').split('\n');
246
263
  const out = [];
247
264
  let inFence = false;
248
- for (const line of lines) {
265
+
266
+ const ROW = /^\s*\|(.+)\|\s*$/;
267
+ const isSep = (l) => l !== undefined && l.includes('|') && l.includes('-') && /^[\s|:-]+$/.test(l.trim());
268
+ const cells = (l) => l.match(ROW)[1].split('|').map((c) => toMarkupInline(c.trim()));
269
+
270
+ for (let i = 0; i < lines.length; i++) {
271
+ const line = lines[i];
272
+
249
273
  const fence = line.match(/^```(\w*)\s*$/);
250
274
  if (fence) {
251
275
  out.push(inFence ? '{code}' : fence[1] ? `{code:${fence[1]}}` : '{code}');
@@ -256,15 +280,25 @@ function createJiraServer(config = {}, env = process.env) {
256
280
  out.push(line);
257
281
  continue;
258
282
  }
283
+
284
+ // Pipe table: a row followed by a `|---|---|` separator. Header cells take
285
+ // `||`, body cells `|`, and the separator row is dropped (Jira has none).
286
+ if (ROW.test(line) && isSep(lines[i + 1])) {
287
+ out.push(`||${cells(line).join('||')}||`);
288
+ i++; // consume the separator
289
+ while (ROW.test(lines[i + 1]) && !isSep(lines[i + 1])) {
290
+ out.push(`|${cells(lines[++i]).join('|')}|`);
291
+ }
292
+ continue;
293
+ }
294
+
259
295
  let s = line;
260
296
  s = s.replace(/^(#{1,6})\s+(.*)$/, (_, h, rest) => `h${h.length}. ${rest}`);
297
+ s = s.replace(/^\s*>\s?(.*)$/, 'bq. $1');
261
298
  s = s.replace(/^(\s*)[-*]\s+/, (_, indent) => '*'.repeat(Math.floor(indent.length / 2) + 1) + ' ');
262
299
  s = s.replace(/^(\s*)\d+\.\s+/, (_, indent) => '#'.repeat(Math.floor(indent.length / 2) + 1) + ' ');
263
- s = s.replace(/\*\*([^*]+)\*\*/g, '*$1*');
264
- s = s.replace(/(^|[^*\w])_([^_]+)_(?=[^*\w]|$)/g, '$1_$2_');
265
- s = s.replace(/`([^`]+)`/g, '{{$1}}');
266
- s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1|$2]');
267
300
  s = s.replace(/^---+$/, '----');
301
+ if (s !== '----') s = toMarkupInline(s);
268
302
  out.push(s);
269
303
  }
270
304
  return out.join('\n');
@@ -0,0 +1,317 @@
1
+ 'use strict';
2
+
3
+ // YouTrack REST client.
4
+ //
5
+ // Ported from the hand-copied `ai/scripts/youtrack.js` in the snap-proof repos,
6
+ // which those repos ran behind a `task-tracker.js` dispatcher alongside the Jira
7
+ // client. Same shape as jira-server.js: a factory returning the shared Tracker
8
+ // interface, nothing calls process.exit, failures throw TrackerError.
9
+ //
10
+ // YouTrack differs from Jira in three ways this file absorbs:
11
+ // - no Task/Sub-task issue-type split — a sub-issue is a plain issue joined to
12
+ // its parent by a "Subtask" issue link (OUTWARD = "parent for").
13
+ // - the workflow field is a "Stage" state bundle, not a transition graph, so
14
+ // any value in the bundle is reachable directly and NoTransitionError almost
15
+ // never fires. An unknown stage name is a TrackerError, not a no-op.
16
+ // - descriptions are markdown-native, so toMarkup is a passthrough.
17
+ //
18
+ // Auth is a permanent token via the Bearer header (YOUTRACK_TOKEN). The project
19
+ // and stage-bundle lookups hit /api/admin/* — the token needs admin-read scope.
20
+
21
+ const { TrackerError, NoTransitionError } = require('./jira-server');
22
+
23
+ const REQUIRED_ENV = ['YOUTRACK_URL', 'YOUTRACK_TOKEN'];
24
+ const STAGE_FIELD = 'Stage';
25
+ const ASSIGNEE_FIELD = 'Assignee';
26
+
27
+ function createYouTrack(config = {}, env = process.env) {
28
+ const baseUrl = (config.baseUrl || env.YOUTRACK_URL || '').replace(/\/+$/, '');
29
+ const token = config.token || env.YOUTRACK_TOKEN;
30
+ const projectKey = config.projectKey || env.YOUTRACK_PROJECT;
31
+
32
+ function assertConfig() {
33
+ const missing = REQUIRED_ENV.filter((k, i) => ![baseUrl, token][i]);
34
+ if (missing.length) {
35
+ throw new TrackerError(
36
+ `Missing YouTrack credentials: ${missing.join(', ')}. Set them in the repo's .env or the environment.`,
37
+ );
38
+ }
39
+ if (!projectKey) {
40
+ throw new TrackerError(
41
+ 'YouTrack project is not set — pass tracker.projectKey in cairn.config.json or YOUTRACK_PROJECT in .env',
42
+ );
43
+ }
44
+ }
45
+
46
+ async function api(pathname, options = {}) {
47
+ assertConfig();
48
+ let res;
49
+ try {
50
+ res = await fetch(`${baseUrl}${pathname}`, {
51
+ ...options,
52
+ headers: {
53
+ Authorization: `Bearer ${token}`,
54
+ 'Content-Type': 'application/json',
55
+ Accept: 'application/json',
56
+ ...(options.headers || {}),
57
+ },
58
+ });
59
+ } catch (err) {
60
+ throw new TrackerError(`Cannot reach YouTrack at ${baseUrl}: ${err.message}`);
61
+ }
62
+ const text = await res.text();
63
+ let body = null;
64
+ if (text) {
65
+ try {
66
+ body = JSON.parse(text);
67
+ } catch {
68
+ body = text;
69
+ }
70
+ }
71
+ if (!res.ok) {
72
+ const detail =
73
+ body && (body.error_description || body.error)
74
+ ? body.error_description || body.error
75
+ : typeof body === 'string'
76
+ ? body.slice(0, 400)
77
+ : JSON.stringify(body);
78
+ throw new TrackerError(`YouTrack ${res.status} on ${pathname}: ${detail}`, {
79
+ status: res.status,
80
+ body,
81
+ });
82
+ }
83
+ return body;
84
+ }
85
+
86
+ // --- internal lookups (admin scope) --------------------------------------
87
+
88
+ async function resolveProjectId() {
89
+ const projects = await api('/api/admin/projects?fields=id,shortName');
90
+ const p = (projects || []).find((pr) => pr.shortName === projectKey);
91
+ if (!p) throw new TrackerError(`YouTrack project "${projectKey}" not found`);
92
+ return p.id;
93
+ }
94
+
95
+ async function resolveStageBundleId(shortName = projectKey) {
96
+ const fields = await api(
97
+ `/api/admin/projects/${shortName}/customFields?fields=field(name),bundle(id)`,
98
+ );
99
+ const stage = (fields || []).find((f) => f.field && f.field.name === STAGE_FIELD);
100
+ if (!stage || !stage.bundle) {
101
+ throw new TrackerError(`No "${STAGE_FIELD}" field on YouTrack project ${shortName}`);
102
+ }
103
+ return stage.bundle.id;
104
+ }
105
+
106
+ async function stageNames() {
107
+ const bundleId = await resolveStageBundleId();
108
+ const bundle = await api(
109
+ `/api/admin/customFieldSettings/bundles/state/${bundleId}?fields=values(name,isResolved)`,
110
+ );
111
+ return (bundle.values || []).map((v) => ({ name: v.name, resolved: !!v.isResolved }));
112
+ }
113
+
114
+ async function issueLinks(key) {
115
+ return api(`/api/issues/${key}/links?fields=direction,linkType(name),issues(idReadable)`);
116
+ }
117
+
118
+ async function childKeys(key) {
119
+ const links = await issueLinks(key);
120
+ const sub = (links || []).find(
121
+ (l) => l.linkType && l.linkType.name === 'Subtask' && l.direction === 'OUTWARD',
122
+ );
123
+ return sub ? sub.issues.map((i) => i.idReadable) : [];
124
+ }
125
+
126
+ async function parentKeyOf(key) {
127
+ const links = await issueLinks(key);
128
+ const sub = (links || []).find(
129
+ (l) => l.linkType && l.linkType.name === 'Subtask' && l.direction === 'INWARD',
130
+ );
131
+ return sub && sub.issues.length ? sub.issues[0].idReadable : null;
132
+ }
133
+
134
+ // --- interface ---------------------------------------------------------
135
+
136
+ async function listProjects() {
137
+ const projects = await api('/api/admin/projects?fields=shortName,name');
138
+ return (projects || []).map((p) => ({ key: p.shortName, name: p.name }));
139
+ }
140
+
141
+ // One "Stage" bundle rather than a per-type status list, so a single group.
142
+ async function listStatuses() {
143
+ const values = await stageNames();
144
+ return [{ type: STAGE_FIELD, statuses: values.map((v) => v.name) }];
145
+ }
146
+
147
+ // `type` is accepted for interface parity and ignored — YouTrack has no split.
148
+ async function createIssue({ summary, description = '' } = {}) {
149
+ if (!summary) throw new TrackerError('createIssue requires a summary');
150
+ const projectId = await resolveProjectId();
151
+ const body = await api('/api/issues?fields=idReadable', {
152
+ method: 'POST',
153
+ body: JSON.stringify({ project: { id: projectId }, summary, description }),
154
+ });
155
+ return body.idReadable;
156
+ }
157
+
158
+ async function createSubIssue({ parentKey, summary, description = '' } = {}) {
159
+ if (!parentKey) throw new TrackerError('createSubIssue requires a parentKey');
160
+ if (!summary) throw new TrackerError('createSubIssue requires a summary');
161
+ const projectId = await resolveProjectId();
162
+ const created = await api('/api/issues?fields=id,idReadable', {
163
+ method: 'POST',
164
+ body: JSON.stringify({ project: { id: projectId }, summary, description }),
165
+ });
166
+ // Join to the parent with a Subtask link via the command API.
167
+ await api('/api/commands', {
168
+ method: 'POST',
169
+ body: JSON.stringify({ issues: [{ id: created.id }], query: `subtask of ${parentKey}` }),
170
+ });
171
+ return created.idReadable;
172
+ }
173
+
174
+ async function get(key) {
175
+ const data = await api(
176
+ `/api/issues/${key}?fields=idReadable,summary,description,customFields(name,value(name,login))`,
177
+ );
178
+ const fields = data.customFields || [];
179
+ const stage = fields.find((f) => f.name === STAGE_FIELD);
180
+ const assignee = fields.find((f) => f.name === ASSIGNEE_FIELD);
181
+ const [parent, subtasks] = await Promise.all([parentKeyOf(key), childKeys(key)]);
182
+ return {
183
+ key: data.idReadable,
184
+ summary: data.summary,
185
+ description: data.description,
186
+ status: (stage && stage.value && stage.value.name) || null,
187
+ assignee: (assignee && assignee.value && assignee.value.login) || null,
188
+ parent,
189
+ subtasks,
190
+ };
191
+ }
192
+
193
+ async function setFields(key, fields = {}) {
194
+ const payload = {};
195
+ if (fields.summary !== undefined) payload.summary = fields.summary;
196
+ if (fields.description !== undefined) payload.description = fields.description;
197
+ if (Object.keys(payload).length === 0) {
198
+ throw new TrackerError('setFields requires at least one of: summary, description');
199
+ }
200
+ await api(`/api/issues/${key}?fields=idReadable`, {
201
+ method: 'POST',
202
+ body: JSON.stringify(payload),
203
+ });
204
+ return key;
205
+ }
206
+
207
+ async function appendDescription(key, text) {
208
+ if (!text) throw new TrackerError('appendDescription requires text');
209
+ const current = await api(`/api/issues/${key}?fields=description`);
210
+ const existing = (current && current.description) || '';
211
+ const updated = existing ? `${existing}\n\n${text}` : text;
212
+ await api(`/api/issues/${key}?fields=idReadable`, {
213
+ method: 'POST',
214
+ body: JSON.stringify({ description: updated }),
215
+ });
216
+ return updated;
217
+ }
218
+
219
+ async function currentStage(key) {
220
+ const data = await api(`/api/issues/${key}?fields=customFields(name,value(name))`);
221
+ const f = (data.customFields || []).find((x) => x.name === STAGE_FIELD);
222
+ return (f && f.value && f.value.name) || null;
223
+ }
224
+
225
+ // Returns { key, from, to, changed }. Already at the target stage is a success
226
+ // with changed:false — re-running a workflow step must be safe. An unknown
227
+ // stage name throws TrackerError (with the valid names), not NoTransitionError:
228
+ // YouTrack has no transition graph, so this is a bad argument, not a dead end.
229
+ async function setStatus(key, statusName) {
230
+ if (!statusName) throw new TrackerError('setStatus requires a status name');
231
+ const from = await currentStage(key);
232
+ if (from && from.toLowerCase() === statusName.toLowerCase()) {
233
+ return { key, from, to: from, changed: false };
234
+ }
235
+ try {
236
+ await api(`/api/issues/${key}?fields=idReadable`, {
237
+ method: 'POST',
238
+ body: JSON.stringify({
239
+ customFields: [
240
+ { name: STAGE_FIELD, $type: 'StateIssueCustomField', value: { name: statusName } },
241
+ ],
242
+ }),
243
+ });
244
+ } catch (err) {
245
+ let names = [];
246
+ try {
247
+ names = (await stageNames()).map((v) => v.name);
248
+ } catch {
249
+ /* keep the original error if the bundle lookup also fails */
250
+ }
251
+ throw new TrackerError(
252
+ `Could not set ${key} ${STAGE_FIELD} to "${statusName}"` +
253
+ (names.length ? ` — valid: ${names.join(', ')}` : `: ${err.message}`),
254
+ );
255
+ }
256
+ return { key, from, to: statusName, changed: true };
257
+ }
258
+
259
+ // Move an issue and its subtasks. Subtasks first. A subtask that can't be
260
+ // moved is collected, not thrown, so one stuck child doesn't strand the rest.
261
+ async function closeIssue(key, statusName = 'Done') {
262
+ const issue = await get(key);
263
+ const results = [];
264
+ for (const subKey of issue.subtasks) {
265
+ try {
266
+ results.push({ ...(await setStatus(subKey, statusName)), ok: true });
267
+ } catch (err) {
268
+ if (!(err instanceof TrackerError)) throw err;
269
+ results.push({ key: subKey, ok: false, reason: err.message });
270
+ }
271
+ }
272
+ try {
273
+ results.push({ ...(await setStatus(key, statusName)), ok: true });
274
+ } catch (err) {
275
+ if (!(err instanceof TrackerError)) throw err;
276
+ results.push({ key, ok: false, reason: err.message });
277
+ }
278
+ return { key, status: statusName, results, failed: results.filter((r) => !r.ok).length };
279
+ }
280
+
281
+ async function setAssignee(key, username) {
282
+ if (!username) throw new TrackerError('setAssignee requires a username');
283
+ await api(`/api/issues/${key}?fields=idReadable`, {
284
+ method: 'POST',
285
+ body: JSON.stringify({
286
+ customFields: [
287
+ { name: ASSIGNEE_FIELD, $type: 'SingleUserIssueCustomField', value: { login: username } },
288
+ ],
289
+ }),
290
+ });
291
+ return { key, assignee: username };
292
+ }
293
+
294
+ // YouTrack renders markdown natively — nothing to convert.
295
+ function toMarkup(markdown) {
296
+ return markdown;
297
+ }
298
+
299
+ return {
300
+ name: 'youtrack',
301
+ projectKey,
302
+ issueTypes: {},
303
+ listProjects,
304
+ listStatuses,
305
+ createIssue,
306
+ createSubIssue,
307
+ get,
308
+ setFields,
309
+ appendDescription,
310
+ setStatus,
311
+ closeIssue,
312
+ setAssignee,
313
+ toMarkup,
314
+ };
315
+ }
316
+
317
+ module.exports = { createYouTrack, TrackerError, NoTransitionError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonepandastudio/cairn",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Shared AI workflow scaffolding for Stone Panda repos — issue tracker client and drift doctor.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/schema.json CHANGED
@@ -22,16 +22,17 @@
22
22
  "properties": {
23
23
  "provider": {
24
24
  "type": "string",
25
- "enum": ["jira-server", "none"],
25
+ "enum": ["jira-server", "youtrack", "none"],
26
26
  "default": "none"
27
27
  },
28
28
  "projectKey": {
29
29
  "type": "string",
30
- "description": "Required unless provider is \"none\".",
30
+ "description": "Required unless provider is \"none\". For youtrack, falls back to YOUTRACK_PROJECT in .env.",
31
31
  "examples": ["PROOF", "GLO"]
32
32
  },
33
33
  "issueTypes": {
34
34
  "type": "object",
35
+ "description": "jira-server only. YouTrack has no Task/Sub-task type split.",
35
36
  "additionalProperties": false,
36
37
  "properties": {
37
38
  "story": { "type": "string", "default": "Story" },