@stonepandastudio/cairn 0.2.0 → 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 +52 -40
- 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
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# @stonepandastudio/cairn
|
|
2
2
|
|
|
3
|
-
Shared AI workflow scaffolding
|
|
3
|
+
Shared AI workflow scaffolding, kept in sync across several repos.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
the steps mirrored into Jira. They were copied between repos by hand and drifted.
|
|
5
|
+
Each repo carries an `ai/` folder and a `.claude/commands/` folder holding the same
|
|
6
|
+
workflow — brief → description → plan → execute → review → QA, with the steps
|
|
7
|
+
mirrored into an issue tracker. Copied between repos by hand, they drift.
|
|
9
8
|
|
|
10
9
|
A cairn is a stack of stones left to mark a trail. That is what this does: it leaves
|
|
11
10
|
markers — a manifest of what it generated, and a report of where the copies stopped
|
|
@@ -13,21 +12,21 @@ agreeing.
|
|
|
13
12
|
|
|
14
13
|
```bash
|
|
15
14
|
npm i -D @stonepandastudio/cairn
|
|
16
|
-
npx cairn init --stack backend --tracker jira-server --project-key
|
|
15
|
+
npx cairn init --stack backend --tracker jira-server --project-key MYPROJ --story-type Task
|
|
17
16
|
```
|
|
18
17
|
|
|
19
18
|
## What v1 does
|
|
20
19
|
|
|
21
20
|
| Command | |
|
|
22
21
|
|---|---|
|
|
23
|
-
| `cairn tracker <cmd>` | Issue tracker client. Replaces the
|
|
22
|
+
| `cairn tracker <cmd>` | Issue tracker client (`jira-server` / `youtrack` / `none`). Replaces the copied `ai/scripts/*.js` clients |
|
|
24
23
|
| `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
|
|
25
24
|
| `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
|
|
26
25
|
|
|
27
26
|
Template rendering — generating the command stubs, `WORKFLOW.md` and the agent role
|
|
28
27
|
contracts from one workflow declaration — is v2, and deliberately not in this release.
|
|
29
|
-
v1 swaps the
|
|
30
|
-
release would make any regression impossible to attribute.
|
|
28
|
+
v1 swaps the tracker client under the consuming repos; bundling prompt changes into
|
|
29
|
+
the same release would make any regression impossible to attribute.
|
|
31
30
|
|
|
32
31
|
## The two folders
|
|
33
32
|
|
|
@@ -37,7 +36,7 @@ repo/
|
|
|
37
36
|
_cairn/ cairn writes this. nobody hand-edits it.
|
|
38
37
|
manifest.json what was generated, from which template, at which hash
|
|
39
38
|
scripts/jira.js vendored shim
|
|
40
|
-
ai/ project-owned. cairn
|
|
39
|
+
ai/ project-owned. cairn never touches it.
|
|
41
40
|
contexts/ progress/ tasks/ infrastructure/
|
|
42
41
|
```
|
|
43
42
|
|
|
@@ -51,38 +50,43 @@ Generated files are **committed**, not gitignored. Claude Code discovers
|
|
|
51
50
|
## Tracker
|
|
52
51
|
|
|
53
52
|
```bash
|
|
54
|
-
cairn tracker list-statuses
|
|
55
|
-
cairn tracker create-task "
|
|
56
|
-
cairn tracker create-subtask
|
|
57
|
-
cairn tracker set-status
|
|
58
|
-
cairn tracker append-description
|
|
59
|
-
cairn tracker close-story
|
|
53
|
+
cairn tracker list-statuses MYPROJ
|
|
54
|
+
cairn tracker create-task "Some task title"
|
|
55
|
+
cairn tracker create-subtask MYPROJ-101 "Step 1: first slice"
|
|
56
|
+
cairn tracker set-status MYPROJ-102 "In Progress"
|
|
57
|
+
cairn tracker append-description MYPROJ-101 ./ai/tasks/MYPROJ-101/brief-step-1.md --markup
|
|
58
|
+
cairn tracker close-story MYPROJ-42 # cascades through subtasks first
|
|
60
59
|
```
|
|
61
60
|
|
|
62
|
-
Providers are `jira-server` and `none
|
|
63
|
-
`
|
|
64
|
-
|
|
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.
|
|
65
67
|
|
|
66
|
-
Three things the
|
|
68
|
+
Three things the copied scripts did that this does not:
|
|
67
69
|
|
|
68
70
|
- **`process.exit` from library code.** Failures throw `TrackerError`; the CLI layer
|
|
69
71
|
chooses the exit code. That is what makes the client callable from a workflow runner.
|
|
70
72
|
- **Fail on a re-run.** Transitioning to a status the issue already holds is a
|
|
71
73
|
successful no-op. `NoTransitionError` exits 0 — re-running a step must be safe.
|
|
72
74
|
- **Ask an agent to convert markup by hand.** `ai/JIRA_MARKUP.md` was a prose document
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
explaining markdown → Jira wiki conversion. It is `toMarkup()` now, under test,
|
|
76
|
+
reachable via `--markup`.
|
|
75
77
|
|
|
76
|
-
Credentials come from the repo's `.env`
|
|
77
|
-
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
|
|
78
82
|
environment wins over the file.
|
|
79
83
|
|
|
80
84
|
### Legacy command names
|
|
81
85
|
|
|
82
86
|
`create-task`, `create-story`, `close-story` and friends are all still accepted, and
|
|
83
|
-
output formats are byte-for-byte what the old scripts printed.
|
|
84
|
-
references
|
|
85
|
-
|
|
87
|
+
output formats are byte-for-byte what the old scripts printed. Existing prose
|
|
88
|
+
references name these strings and the agents parse the output, so they are kept
|
|
89
|
+
until the v2 pass regenerates those documents.
|
|
86
90
|
|
|
87
91
|
## Doctor
|
|
88
92
|
|
|
@@ -93,13 +97,15 @@ cairn doctor --json
|
|
|
93
97
|
cairn doctor --strict # exit 1 on drift or a hand-edited generated file
|
|
94
98
|
```
|
|
95
99
|
|
|
100
|
+
Run it from the directory holding `repos.json`, or pass `--config <file>`.
|
|
101
|
+
|
|
96
102
|
It reports two independent axes, and keeping them apart is the point:
|
|
97
103
|
|
|
98
104
|
**Drift** — cross-repo. Do the copies still agree? Raw comparison is noise, so each
|
|
99
|
-
file is normalized first: BOM and CRLF stripped, repo paths and names folded,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
file is normalized first: BOM and CRLF stripped, repo paths and names folded, issue
|
|
106
|
+
keys folded to `{{ISSUE}}`, and declared vars folded to `{{placeholder}}`. What
|
|
107
|
+
survives is drift no template variable explains. Files are then clustered by
|
|
108
|
+
normalized content; the largest cluster becomes the reference.
|
|
103
109
|
|
|
104
110
|
| Status | Meaning |
|
|
105
111
|
|---|---|
|
|
@@ -116,8 +122,11 @@ match what is on disk?
|
|
|
116
122
|
|---|---|
|
|
117
123
|
| `MANAGED` | on-disk hash matches what cairn wrote |
|
|
118
124
|
| `MODIFIED` | someone hand-edited a generated file |
|
|
119
|
-
| `OUTDATED` | cairn has newer content for it |
|
|
120
|
-
| `DELETED` | it is gone; `sync` will restore it |
|
|
125
|
+
| `OUTDATED` | cairn has newer content for it — needs template hashes, wired in v2 |
|
|
126
|
+
| `DELETED` | it is gone; a future `cairn sync` will restore it |
|
|
127
|
+
|
|
128
|
+
v1 reports `MANAGED`, `MODIFIED` and `DELETED`. `OUTDATED` and the `sync` that acts
|
|
129
|
+
on these states arrive with the v2 renderer.
|
|
121
130
|
|
|
122
131
|
A file can be perfectly in sync across repos while being hand-edited away from its
|
|
123
132
|
template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
|
|
@@ -128,17 +137,20 @@ overwriting a hand edit is the destructive outcome.
|
|
|
128
137
|
- `repos[]` — name, path, stack. Per-repo settings live in each repo's own
|
|
129
138
|
`cairn.config.json`; the inline `vars` blocks are a fallback used only until a repo
|
|
130
139
|
has been through `cairn init`, so migration can happen one repo at a time.
|
|
131
|
-
- `pathAliases` — local → canonical.
|
|
132
|
-
|
|
133
|
-
reporting one `MISSING` and one `STRANDED` for
|
|
140
|
+
- `pathAliases` — local → canonical. When one repo names a stub `execute-tests.md`
|
|
141
|
+
where the others use `execute-test-plan.md`, aliasing folds them onto the cohort's
|
|
142
|
+
names instead of reporting one `MISSING` and one `STRANDED` for the same file.
|
|
134
143
|
- `shared[]` — glob rules. `cohort: "all"` compares every repo; `cohort: "stack"`
|
|
135
144
|
compares backend-with-backend. `requires: { tracker: true }` skips repos with no
|
|
136
145
|
tracker. A repo with no config has an *unknown* tracker, not an absent one, and is
|
|
137
146
|
never skipped on that basis.
|
|
138
147
|
- Anything not matched by a rule — `ai/contexts/`, `ai/progress/`, `ai/tasks/`,
|
|
139
148
|
`DATABASE_SCHEMA.md` — is per-project by design and never inspected.
|
|
140
|
-
`ai/INITIAL_PROMPT.md` is explicitly excluded:
|
|
141
|
-
|
|
149
|
+
`ai/INITIAL_PROMPT.md` is explicitly excluded: variants at ~0% shared content make
|
|
150
|
+
it a per-project document, not a drifted template.
|
|
151
|
+
|
|
152
|
+
v2 splits this flat file into a per-project `workspace.json` plus an opt-in hub that
|
|
153
|
+
references the projects for cross-project comparison. See `DESIGN.md`.
|
|
142
154
|
|
|
143
155
|
## Layout
|
|
144
156
|
|
|
@@ -147,7 +159,7 @@ bin/cairn.js subcommand router
|
|
|
147
159
|
lib/config.js cairn.config.json + .env loading
|
|
148
160
|
lib/manifest.js generated-file hashing and state
|
|
149
161
|
lib/paint.js ANSI + table rendering
|
|
150
|
-
lib/tracker/ index (registry), cli, jira-server, none
|
|
162
|
+
lib/tracker/ index (registry), cli, jira-server, youtrack, none
|
|
151
163
|
lib/doctor/ index (analysis + report), scan, normalize, diff
|
|
152
164
|
lib/init.js cairn init
|
|
153
165
|
templates/shims/ vendored shim source
|
|
@@ -155,4 +167,4 @@ schema.json JSON Schema for cairn.config.json
|
|
|
155
167
|
test/run.js dependency-free test runner
|
|
156
168
|
```
|
|
157
169
|
|
|
158
|
-
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" },
|