@stonepandastudio/cairn 0.2.1 → 0.3.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.
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 46 tests.
package/lib/init.js CHANGED
@@ -89,8 +89,11 @@ function buildConfig(args) {
89
89
  };
90
90
  if (args.tracker !== 'none') {
91
91
  cfg.tracker.projectKey = args.projectKey;
92
- cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
93
92
  cfg.tracker.env = '.env';
93
+ // issueTypes is a Jira concept — YouTrack has no Task/Sub-task split.
94
+ if (args.tracker === 'jira-server') {
95
+ cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
96
+ }
94
97
  }
95
98
  cfg.agents = {};
96
99
  return cfg;
@@ -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.0",
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" },