@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 +13 -8
- package/lib/init.js +4 -1
- package/lib/tracker/index.js +2 -0
- package/lib/tracker/jira-server.js +39 -5
- package/lib/tracker/youtrack.js +317 -0
- package/package.json +1 -1
- package/schema.json +3 -2
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
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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`
|
|
76
|
-
Jira Server 8.5.1 predates
|
|
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
|
|
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;
|
package/lib/tracker/index.js
CHANGED
|
@@ -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
|
-
|
|
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
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" },
|