@stonepandastudio/cairn 0.2.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 +158 -0
- package/bin/cairn.js +63 -0
- package/lib/config.js +146 -0
- package/lib/doctor/diff.js +127 -0
- package/lib/doctor/index.js +517 -0
- package/lib/doctor/normalize.js +83 -0
- package/lib/doctor/scan.js +84 -0
- package/lib/index.js +20 -0
- package/lib/init.js +186 -0
- package/lib/manifest.js +120 -0
- package/lib/paint.js +41 -0
- package/lib/tracker/cli.js +240 -0
- package/lib/tracker/index.js +51 -0
- package/lib/tracker/jira-server.js +291 -0
- package/lib/tracker/none.js +55 -0
- package/package.json +43 -0
- package/schema.json +71 -0
- package/templates/shims/jira.js +12 -0
package/lib/init.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { CONFIG_NAME, configPath, cairnDir } = require('./config');
|
|
7
|
+
const {
|
|
8
|
+
emptyManifest,
|
|
9
|
+
readManifest,
|
|
10
|
+
record,
|
|
11
|
+
writeManifest,
|
|
12
|
+
manifestPath,
|
|
13
|
+
} = require('./manifest');
|
|
14
|
+
const { listProviders } = require('./tracker');
|
|
15
|
+
const { makePaint } = require('./paint');
|
|
16
|
+
|
|
17
|
+
// `cairn init` — make a repo cairn-managed.
|
|
18
|
+
//
|
|
19
|
+
// It writes exactly three things:
|
|
20
|
+
// cairn.config.json hand-editable from here on; cairn never rewrites it
|
|
21
|
+
// _cairn/scripts/jira.js vendored shim, tracked in the manifest
|
|
22
|
+
// _cairn/manifest.json the record of what was written
|
|
23
|
+
//
|
|
24
|
+
// Plus, when the repo still carries a copied ai/scripts/jira.js, that file is
|
|
25
|
+
// replaced by a shim too. Replacing rather than deleting is deliberate: the five
|
|
26
|
+
// repos have roughly forty prose references to that path, and breaking them all
|
|
27
|
+
// in the same release that swaps the Jira client would make any failure
|
|
28
|
+
// impossible to attribute.
|
|
29
|
+
|
|
30
|
+
const SHIM_SOURCE = 'shims/jira.js';
|
|
31
|
+
const SHIM_TARGET = '_cairn/scripts/jira.js';
|
|
32
|
+
const LEGACY_SHIM_TARGET = 'ai/scripts/jira.js';
|
|
33
|
+
|
|
34
|
+
const HELP = `cairn init — make a repo cairn-managed
|
|
35
|
+
|
|
36
|
+
Usage: cairn init [options]
|
|
37
|
+
|
|
38
|
+
--repo <path> repo to initialise (default: cwd)
|
|
39
|
+
--stack <name> backend | frontend | cli | ... (default: unknown)
|
|
40
|
+
--tracker <provider> ${listProviders().join(' | ')} (default: none)
|
|
41
|
+
--project-key <KEY> tracker project key, e.g. PROOF
|
|
42
|
+
--story-type <name> parent issue type (default: Story)
|
|
43
|
+
--subtask-type <name> child issue type (default: Sub-task)
|
|
44
|
+
--no-shim do not vendor the ai/scripts/jira.js compatibility shim
|
|
45
|
+
--force overwrite an existing cairn.config.json
|
|
46
|
+
--dry-run print what would be written, write nothing
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const args = {
|
|
51
|
+
repo: process.cwd(),
|
|
52
|
+
stack: 'unknown',
|
|
53
|
+
tracker: 'none',
|
|
54
|
+
projectKey: null,
|
|
55
|
+
storyType: 'Story',
|
|
56
|
+
subtaskType: 'Sub-task',
|
|
57
|
+
shim: true,
|
|
58
|
+
force: false,
|
|
59
|
+
dryRun: false,
|
|
60
|
+
color: process.stdout.isTTY,
|
|
61
|
+
};
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
if (a === '--repo') args.repo = argv[++i];
|
|
65
|
+
else if (a === '--stack') args.stack = argv[++i];
|
|
66
|
+
else if (a === '--tracker') args.tracker = argv[++i];
|
|
67
|
+
else if (a === '--project-key') args.projectKey = argv[++i];
|
|
68
|
+
else if (a === '--story-type') args.storyType = argv[++i];
|
|
69
|
+
else if (a === '--subtask-type') args.subtaskType = argv[++i];
|
|
70
|
+
else if (a === '--no-shim') args.shim = false;
|
|
71
|
+
else if (a === '--force') args.force = true;
|
|
72
|
+
else if (a === '--dry-run') args.dryRun = true;
|
|
73
|
+
else if (a === '--no-color') args.color = false;
|
|
74
|
+
else if (a === '-h' || a === '--help') args.help = true;
|
|
75
|
+
else {
|
|
76
|
+
console.error(`Unknown argument: ${a}`);
|
|
77
|
+
return { error: 2 };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return args;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildConfig(args) {
|
|
84
|
+
const cfg = {
|
|
85
|
+
$schema: `./node_modules/${require('../package.json').name}/schema.json`,
|
|
86
|
+
cairn: require('../package.json').version,
|
|
87
|
+
stack: args.stack,
|
|
88
|
+
tracker: { provider: args.tracker },
|
|
89
|
+
};
|
|
90
|
+
if (args.tracker !== 'none') {
|
|
91
|
+
cfg.tracker.projectKey = args.projectKey;
|
|
92
|
+
cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
|
|
93
|
+
cfg.tracker.env = '.env';
|
|
94
|
+
}
|
|
95
|
+
cfg.agents = {};
|
|
96
|
+
return cfg;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readTemplate(rel) {
|
|
100
|
+
return fs.readFileSync(path.join(__dirname, '..', 'templates', rel), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function main(argv = process.argv.slice(2)) {
|
|
104
|
+
const args = parseArgs(argv);
|
|
105
|
+
if (args.error) return args.error;
|
|
106
|
+
if (args.help) {
|
|
107
|
+
process.stdout.write(HELP);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const paint = makePaint(args.color);
|
|
112
|
+
const repoPath = path.resolve(args.repo);
|
|
113
|
+
|
|
114
|
+
if (!fs.existsSync(repoPath)) {
|
|
115
|
+
console.error(`Repo not found: ${repoPath}`);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
if (!listProviders().includes(args.tracker)) {
|
|
119
|
+
console.error(`Unknown tracker "${args.tracker}". Available: ${listProviders().join(', ')}`);
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
if (args.tracker !== 'none' && !args.projectKey) {
|
|
123
|
+
console.error(`--tracker ${args.tracker} requires --project-key`);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cfgFile = configPath(repoPath);
|
|
128
|
+
if (fs.existsSync(cfgFile) && !args.force) {
|
|
129
|
+
console.error(`${cfgFile} already exists — pass --force to overwrite`);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const planned = [];
|
|
134
|
+
const config = buildConfig(args);
|
|
135
|
+
planned.push([CONFIG_NAME, JSON.stringify(config, null, 2) + '\n', null]);
|
|
136
|
+
|
|
137
|
+
const shim = readTemplate(SHIM_SOURCE);
|
|
138
|
+
if (args.shim) {
|
|
139
|
+
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]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (args.dryRun) {
|
|
148
|
+
console.log(paint.bold(`cairn init --dry-run ${repoPath}`));
|
|
149
|
+
for (const [rel] of planned) console.log(` would write ${rel}`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const manifest = readManifest(repoPath) || emptyManifest();
|
|
154
|
+
for (const [rel, content, source] of planned) {
|
|
155
|
+
const abs = path.join(repoPath, rel);
|
|
156
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
157
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
158
|
+
// cairn.config.json is hand-editable, so it is deliberately not manifested —
|
|
159
|
+
// tracking it would report every legitimate edit as MODIFIED.
|
|
160
|
+
if (source) record(manifest, rel, source, content);
|
|
161
|
+
}
|
|
162
|
+
writeManifest(repoPath, manifest);
|
|
163
|
+
|
|
164
|
+
console.log(paint.bold(`cairn init ${repoPath}`));
|
|
165
|
+
for (const [rel, , source] of planned) {
|
|
166
|
+
console.log(` ${paint.green('wrote')} ${rel}${source ? paint.dim(` (${source})`) : ''}`);
|
|
167
|
+
}
|
|
168
|
+
console.log(` ${paint.green('wrote')} ${path.relative(repoPath, manifestPath(repoPath)).replace(/\\/g, '/')}`);
|
|
169
|
+
|
|
170
|
+
const gitignore = path.join(repoPath, '.gitignore');
|
|
171
|
+
const hasIgnore = fs.existsSync(gitignore) && /^_cairn\b/m.test(fs.readFileSync(gitignore, 'utf8'));
|
|
172
|
+
if (hasIgnore) {
|
|
173
|
+
console.log(
|
|
174
|
+
paint.yellow(` warning: _cairn/ is gitignored in this repo — generated files must be committed`),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (args.tracker !== 'none') {
|
|
178
|
+
console.log(
|
|
179
|
+
paint.dim(`\n Next: ensure .env has JIRA_BASE_URL, JIRA_USER, JIRA_PASSWORD, then run`),
|
|
180
|
+
);
|
|
181
|
+
console.log(paint.dim(` npx cairn tracker list-statuses ${args.projectKey}`));
|
|
182
|
+
}
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { main, buildConfig, HELP, SHIM_TARGET, LEGACY_SHIM_TARGET, cairnDir };
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const { CAIRN_DIR, cairnDir } = require('./config');
|
|
8
|
+
|
|
9
|
+
const MANIFEST_NAME = 'manifest.json';
|
|
10
|
+
const MANIFEST_VERSION = 1;
|
|
11
|
+
const GENERATOR = require('../package.json').name;
|
|
12
|
+
|
|
13
|
+
// The manifest is what makes "generated" auditable. Without it, a vendored file
|
|
14
|
+
// that someone hand-edited is indistinguishable from one cairn wrote — which is
|
|
15
|
+
// exactly the failure mode that produced the drift this tool exists to report.
|
|
16
|
+
//
|
|
17
|
+
// Each entry records the template it came from and the hash of what cairn wrote.
|
|
18
|
+
// Comparing that to the hash on disk gives three states instead of one:
|
|
19
|
+
//
|
|
20
|
+
// MANAGED on-disk hash === written hash nothing to do
|
|
21
|
+
// MODIFIED on-disk hash !== written hash someone edited it by hand
|
|
22
|
+
// OUTDATED written hash !== current template hash cairn has newer content
|
|
23
|
+
// DELETED file is gone sync will restore it
|
|
24
|
+
|
|
25
|
+
function manifestPath(repoPath) {
|
|
26
|
+
return path.join(cairnDir(repoPath), MANIFEST_NAME);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Normalize before hashing so a CRLF checkout doesn't read as a hand edit. This
|
|
30
|
+
// matters here specifically: these repos are Windows-side with mixed .gitattributes.
|
|
31
|
+
function hashContent(text) {
|
|
32
|
+
const normalized = String(text).replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
33
|
+
return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 16);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emptyManifest() {
|
|
37
|
+
return { version: MANIFEST_VERSION, generator: GENERATOR, files: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readManifest(repoPath) {
|
|
41
|
+
const file = manifestPath(repoPath);
|
|
42
|
+
if (!fs.existsSync(file)) return null;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
45
|
+
if (!parsed.files) parsed.files = {};
|
|
46
|
+
return parsed;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`${file}: invalid JSON — ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeManifest(repoPath, manifest) {
|
|
53
|
+
const dir = cairnDir(repoPath);
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
const ordered = {
|
|
56
|
+
version: MANIFEST_VERSION,
|
|
57
|
+
generator: GENERATOR,
|
|
58
|
+
generatedAt: new Date().toISOString(),
|
|
59
|
+
files: Object.fromEntries(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b))),
|
|
60
|
+
};
|
|
61
|
+
fs.writeFileSync(manifestPath(repoPath), JSON.stringify(ordered, null, 2) + '\n', 'utf8');
|
|
62
|
+
return ordered;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Record that cairn wrote `content` to `relPath` from template `source`.
|
|
66
|
+
function record(manifest, relPath, source, content, extra = {}) {
|
|
67
|
+
manifest.files[relPath.replace(/\\/g, '/')] = {
|
|
68
|
+
source,
|
|
69
|
+
hash: hashContent(content),
|
|
70
|
+
...extra,
|
|
71
|
+
};
|
|
72
|
+
return manifest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Compare every manifest entry against what is actually on disk. `templateHashes`
|
|
76
|
+
// is an optional { source -> hash } map; supply it to detect OUTDATED as well.
|
|
77
|
+
function statusFor(repoPath, manifest, templateHashes = null) {
|
|
78
|
+
const results = [];
|
|
79
|
+
for (const [rel, entry] of Object.entries(manifest.files)) {
|
|
80
|
+
const abs = path.join(repoPath, rel);
|
|
81
|
+
if (!fs.existsSync(abs)) {
|
|
82
|
+
results.push({ path: rel, source: entry.source, state: 'DELETED' });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const onDisk = hashContent(fs.readFileSync(abs, 'utf8'));
|
|
86
|
+
if (onDisk !== entry.hash) {
|
|
87
|
+
results.push({ path: rel, source: entry.source, state: 'MODIFIED' });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (templateHashes && templateHashes[entry.source] && templateHashes[entry.source] !== entry.hash) {
|
|
91
|
+
results.push({ path: rel, source: entry.source, state: 'OUTDATED' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
results.push({ path: rel, source: entry.source, state: 'MANAGED' });
|
|
95
|
+
}
|
|
96
|
+
return results.sort((a, b) => a.path.localeCompare(b.path));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Header stamped into every generated text file. `cairn doctor` reads the
|
|
100
|
+
// manifest rather than this comment, but the comment is what a human sees first.
|
|
101
|
+
function header(source, content, comment = 'html') {
|
|
102
|
+
const line = `cairn:generated source=${source} hash=${hashContent(content)} — edit cairn.config.json, not this file`;
|
|
103
|
+
if (comment === 'js') return `// ${line}\n`;
|
|
104
|
+
if (comment === 'hash') return `# ${line}\n`;
|
|
105
|
+
return `<!-- ${line} -->\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
CAIRN_DIR,
|
|
110
|
+
MANIFEST_NAME,
|
|
111
|
+
MANIFEST_VERSION,
|
|
112
|
+
emptyManifest,
|
|
113
|
+
hashContent,
|
|
114
|
+
header,
|
|
115
|
+
manifestPath,
|
|
116
|
+
readManifest,
|
|
117
|
+
record,
|
|
118
|
+
statusFor,
|
|
119
|
+
writeManifest,
|
|
120
|
+
};
|
package/lib/paint.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tiny ANSI helper. Every command takes the same --color / --no-color pair, so
|
|
4
|
+
// the enabled flag is decided once at the CLI boundary and threaded down.
|
|
5
|
+
|
|
6
|
+
function makePaint(enabled) {
|
|
7
|
+
const wrap = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
8
|
+
return {
|
|
9
|
+
enabled,
|
|
10
|
+
bold: wrap(1),
|
|
11
|
+
dim: wrap(2),
|
|
12
|
+
red: wrap(31),
|
|
13
|
+
green: wrap(32),
|
|
14
|
+
yellow: wrap(33),
|
|
15
|
+
blue: wrap(34),
|
|
16
|
+
magenta: wrap(35),
|
|
17
|
+
cyan: wrap(36),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stripAnsi(s) {
|
|
22
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Column-aligned table that measures visible width, not byte width, so coloured
|
|
26
|
+
// cells don't blow up the alignment.
|
|
27
|
+
function table(rows, headers, paint) {
|
|
28
|
+
const widths = headers.map((h, i) =>
|
|
29
|
+
Math.max(h.length, ...rows.map((r) => stripAnsi(r[i]).length)),
|
|
30
|
+
);
|
|
31
|
+
const line = (cells) =>
|
|
32
|
+
cells
|
|
33
|
+
.map((cell, i) => String(cell) + ' '.repeat(widths[i] - stripAnsi(cell).length))
|
|
34
|
+
.join(' ')
|
|
35
|
+
.trimEnd();
|
|
36
|
+
const out = [paint.bold(line(headers)), paint.dim(widths.map((w) => '─'.repeat(w)).join(' '))];
|
|
37
|
+
for (const row of rows) out.push(line(row));
|
|
38
|
+
return out.join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { makePaint, stripAnsi, table };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const { trackerForRepo, TrackerError, NoTransitionError } = require('./index');
|
|
6
|
+
const { ConfigError } = require('../config');
|
|
7
|
+
|
|
8
|
+
// Command-line surface for the tracker.
|
|
9
|
+
//
|
|
10
|
+
// Every command name from the four replaced `ai/scripts/jira.js` copies is kept,
|
|
11
|
+
// including the ones that only existed in one repo (`create-story` in glossr,
|
|
12
|
+
// `create-task` in snap, `close-story` in glossr-frontend). They are aliases onto
|
|
13
|
+
// one implementation now, but the names stay because agent docs, command stubs
|
|
14
|
+
// and WORKFLOW.md across five repos reference them by string. Renaming them is a
|
|
15
|
+
// v2 concern, done in the same pass that regenerates those files.
|
|
16
|
+
//
|
|
17
|
+
// Output shapes are also preserved verbatim — `create-*` prints the bare key,
|
|
18
|
+
// `get` prints JSON, `set-status` prints `KEY -> Status` — because the agents
|
|
19
|
+
// read this output. Changing it would silently break prompts.
|
|
20
|
+
|
|
21
|
+
const ALIASES = {
|
|
22
|
+
'create-task': 'create-issue',
|
|
23
|
+
'create-story': 'create-issue',
|
|
24
|
+
'create-subtask': 'create-subissue',
|
|
25
|
+
'close-story': 'close',
|
|
26
|
+
'close-task': 'close',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const USAGE = `cairn tracker <command> [...args]
|
|
30
|
+
|
|
31
|
+
list-projects
|
|
32
|
+
list-statuses [projectKey]
|
|
33
|
+
create-task | create-story <summary> [descriptionOrFile]
|
|
34
|
+
create-subtask <parentKey> <summary> [descriptionOrFile]
|
|
35
|
+
get <key>
|
|
36
|
+
set-fields <key> [--summary ..] [--description ..] [--description-file ..]
|
|
37
|
+
append-description <key> <textOrFile>
|
|
38
|
+
set-status <key> <statusName>
|
|
39
|
+
close-story <key> [statusName=Done]
|
|
40
|
+
set-assignee <key> <username>
|
|
41
|
+
|
|
42
|
+
--markup convert markdown input to Jira wiki markup before sending
|
|
43
|
+
--json print machine-readable output where the command supports it
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
function parseFlags(args) {
|
|
47
|
+
const flags = {};
|
|
48
|
+
const rest = [];
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const arg = args[i];
|
|
51
|
+
if (arg.startsWith('--')) {
|
|
52
|
+
const key = arg.slice(2);
|
|
53
|
+
const next = args[i + 1];
|
|
54
|
+
if (next === undefined || next.startsWith('--')) {
|
|
55
|
+
flags[key] = true;
|
|
56
|
+
} else {
|
|
57
|
+
flags[key] = next;
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
rest.push(arg);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { flags, rest };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A value that names an existing file is read as one; otherwise it is the text.
|
|
68
|
+
// Inherited behaviour — the workflow passes description files by path.
|
|
69
|
+
function readMaybeFile(value) {
|
|
70
|
+
if (value === undefined || value === true) return undefined;
|
|
71
|
+
try {
|
|
72
|
+
if (fs.existsSync(value) && fs.statSync(value).isFile()) return fs.readFileSync(value, 'utf8');
|
|
73
|
+
} catch {
|
|
74
|
+
/* fall through to literal */
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function run(argv, { cwd = process.cwd(), out = console.log, err = console.error } = {}) {
|
|
80
|
+
const [rawCommand, ...args] = argv;
|
|
81
|
+
if (!rawCommand || rawCommand === '--help' || rawCommand === '-h') {
|
|
82
|
+
out(USAGE);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const command = ALIASES[rawCommand] || rawCommand;
|
|
87
|
+
const { flags, rest } = parseFlags(args);
|
|
88
|
+
|
|
89
|
+
let tracker;
|
|
90
|
+
try {
|
|
91
|
+
({ tracker } = trackerForRepo(cwd));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (e instanceof ConfigError) {
|
|
94
|
+
err(e.message);
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const markup = (text) => (flags.markup && text !== undefined ? tracker.toMarkup(text) : text);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
switch (command) {
|
|
104
|
+
case 'list-projects': {
|
|
105
|
+
const projects = await tracker.listProjects();
|
|
106
|
+
if (flags.json) out(JSON.stringify(projects, null, 2));
|
|
107
|
+
else for (const p of projects) out(`${p.key} - ${p.name}`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case 'list-statuses': {
|
|
112
|
+
const groups = await tracker.listStatuses(rest[0]);
|
|
113
|
+
if (flags.json) out(JSON.stringify(groups, null, 2));
|
|
114
|
+
else for (const g of groups) out(`${g.type}: ${g.statuses.join(', ')}`);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case 'create-issue': {
|
|
119
|
+
const key = await tracker.createIssue({
|
|
120
|
+
summary: rest[0],
|
|
121
|
+
description: markup(readMaybeFile(rest[1])) || '',
|
|
122
|
+
type: flags.type,
|
|
123
|
+
});
|
|
124
|
+
if (key === null) {
|
|
125
|
+
err('tracker.provider is "none" — no issue created');
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
out(key);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'create-subissue': {
|
|
133
|
+
const key = await tracker.createSubIssue({
|
|
134
|
+
parentKey: rest[0],
|
|
135
|
+
summary: rest[1],
|
|
136
|
+
description: markup(readMaybeFile(rest[2])) || '',
|
|
137
|
+
type: flags.type,
|
|
138
|
+
});
|
|
139
|
+
if (key === null) {
|
|
140
|
+
err('tracker.provider is "none" — no sub-issue created');
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
out(key);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'get': {
|
|
148
|
+
const issue = await tracker.get(rest[0]);
|
|
149
|
+
out(JSON.stringify(issue, null, 2));
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'set-fields': {
|
|
154
|
+
const descArg = flags['description-file'] !== undefined ? flags['description-file'] : flags.description;
|
|
155
|
+
const fields = {};
|
|
156
|
+
if (flags.summary !== undefined && flags.summary !== true) fields.summary = flags.summary;
|
|
157
|
+
if (descArg !== undefined) fields.description = markup(readMaybeFile(descArg));
|
|
158
|
+
await tracker.setFields(rest[0], fields);
|
|
159
|
+
out(`${rest[0]} updated`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'append-description': {
|
|
164
|
+
await tracker.appendDescription(rest[0], markup(readMaybeFile(rest[1])));
|
|
165
|
+
out(`${rest[0]} description appended`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case 'set-status': {
|
|
170
|
+
const result = await tracker.setStatus(rest[0], rest[1]);
|
|
171
|
+
// Never print `KEY -> Status` for something that did not happen — the
|
|
172
|
+
// agents read this line as confirmation the board was actually moved.
|
|
173
|
+
if (result.skipped) err(`${result.key}: ${result.reason} — status not changed`);
|
|
174
|
+
else if (result.changed === false) out(`${result.key} already ${result.to}`);
|
|
175
|
+
else out(`${result.key} -> ${result.to}`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case 'close': {
|
|
180
|
+
const status = rest[1] || 'Done';
|
|
181
|
+
const result = await tracker.closeIssue(rest[0], status);
|
|
182
|
+
if (result.skipped) {
|
|
183
|
+
err(`${result.key}: ${result.reason} — nothing closed`);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
for (const r of result.results) {
|
|
187
|
+
if (r.ok) out(`${r.key} -> ${r.to}`);
|
|
188
|
+
else err(`${r.key}: ${r.reason}`);
|
|
189
|
+
}
|
|
190
|
+
if (result.failed) {
|
|
191
|
+
err(`${result.failed} issue(s) could not be moved to "${status}".`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
out(`${result.key} and all subtasks are now "${status}".`);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'set-assignee': {
|
|
199
|
+
const result = await tracker.setAssignee(rest[0], rest[1]);
|
|
200
|
+
out(`${result.key} assigned to ${result.assignee}`);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
default:
|
|
205
|
+
err(`Unknown command: ${rawCommand}`);
|
|
206
|
+
err(USAGE);
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// A missing transition means the issue is already at or past the target
|
|
211
|
+
// status on a re-run. Report it, but don't fail the workflow step.
|
|
212
|
+
if (e instanceof NoTransitionError) {
|
|
213
|
+
err(e.message);
|
|
214
|
+
if (e.available.length) err(` available: ${e.available.join(', ')}`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if (e instanceof TrackerError || e instanceof ConfigError) {
|
|
218
|
+
err(e.message);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Entry point used by the vendored shim: `require('@stonepandastudio/cairn/tracker/cli')(args)`.
|
|
226
|
+
function main(argv = process.argv.slice(2)) {
|
|
227
|
+
run(argv)
|
|
228
|
+
.then((code) => {
|
|
229
|
+
if (code) process.exitCode = code;
|
|
230
|
+
})
|
|
231
|
+
.catch((e) => {
|
|
232
|
+
console.error(e);
|
|
233
|
+
process.exitCode = 1;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = main;
|
|
238
|
+
module.exports.run = run;
|
|
239
|
+
module.exports.parseFlags = parseFlags;
|
|
240
|
+
module.exports.USAGE = USAGE;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { loadEnv, loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
|
|
4
|
+
const { createJiraServer, TrackerError, NoTransitionError } = require('./jira-server');
|
|
5
|
+
const { createNone } = require('./none');
|
|
6
|
+
|
|
7
|
+
// Provider registry. Adding Linear or GitHub Issues later means adding one entry
|
|
8
|
+
// here and one file next to jira-server.js — the workflow layer above only ever
|
|
9
|
+
// sees the shared interface.
|
|
10
|
+
const PROVIDERS = {
|
|
11
|
+
'jira-server': createJiraServer,
|
|
12
|
+
none: createNone,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function listProviders() {
|
|
16
|
+
return Object.keys(PROVIDERS);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Build a tracker straight from a tracker config block plus an env bag.
|
|
20
|
+
function createTracker(trackerConfig = {}, env = process.env) {
|
|
21
|
+
const name = trackerConfig.provider || 'none';
|
|
22
|
+
const factory = PROVIDERS[name];
|
|
23
|
+
if (!factory) {
|
|
24
|
+
throw new ConfigError(
|
|
25
|
+
`Unknown tracker provider "${name}". Available: ${listProviders().join(', ')}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return factory(trackerConfig, env);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build a tracker for a repo: find the repo root, read cairn.config.json, load
|
|
32
|
+
// its .env, then construct. This is the entry point the CLI and the shim use.
|
|
33
|
+
function trackerForRepo(startDir = process.cwd()) {
|
|
34
|
+
const repoPath = findRepoRoot(startDir);
|
|
35
|
+
if (!repoPath) {
|
|
36
|
+
throw new ConfigError(
|
|
37
|
+
`No cairn.config.json found in ${startDir} or any parent — run \`cairn init\` in the repo root`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const config = loadRepoConfig(repoPath, { required: true });
|
|
41
|
+
loadEnv(repoPath, config.tracker.env);
|
|
42
|
+
return { tracker: createTracker(config.tracker), config, repoPath };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
createTracker,
|
|
47
|
+
trackerForRepo,
|
|
48
|
+
listProviders,
|
|
49
|
+
TrackerError,
|
|
50
|
+
NoTransitionError,
|
|
51
|
+
};
|