storymaps 1.1.0 → 1.1.2
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 +25 -31
- package/bin/storymaps.js +5 -11
- package/lib/util/file.js +0 -19
- package/package.json +1 -1
- package/lib/commands/import.js +0 -249
- package/lib/util/phabricator.js +0 -155
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Storymaps CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Turn your [storymaps.io](https://storymaps.io) diagrams into YAML files you can version-control, diff in pull requests, and sync with the server - all from your terminal.
|
|
4
4
|
|
|
5
|
-
Story maps
|
|
5
|
+
Story maps are structured data. The CLI lets you export the structure behind the diagram, edit it as code, and push changes back to the browser.
|
|
6
6
|
|
|
7
7
|
```diff
|
|
8
8
|
# What a story map change looks like in a pull request
|
|
@@ -36,7 +36,7 @@ npm link
|
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
storymaps init # create a new storymap.yml
|
|
39
|
-
storymaps open #
|
|
39
|
+
storymaps open # open it in the browser
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
Or pull an existing map:
|
|
@@ -45,15 +45,17 @@ Or pull an existing map:
|
|
|
45
45
|
storymaps pull storymaps.io/abc123 # download as YAML
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Edit the YAML
|
|
48
|
+
Edit the YAML, then push it back:
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
storymaps diff # see what changed
|
|
52
|
-
storymaps push #
|
|
52
|
+
storymaps push # sync to the server
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
## Status
|
|
56
56
|
|
|
57
|
+
See the project status in a snapshot:
|
|
58
|
+
|
|
57
59
|
```bash
|
|
58
60
|
> storymaps status
|
|
59
61
|
|
|
@@ -77,7 +79,7 @@ Blocked
|
|
|
77
79
|
● Scan QR code
|
|
78
80
|
```
|
|
79
81
|
|
|
80
|
-
Non-task cards (notes, questions) are excluded from
|
|
82
|
+
Non-task cards (notes, questions) are excluded from counts based on the legend.
|
|
81
83
|
|
|
82
84
|
## Commands
|
|
83
85
|
|
|
@@ -93,7 +95,7 @@ Creates `storymap.yml` and registers it on the server. If you're offline, the fi
|
|
|
93
95
|
### `open`
|
|
94
96
|
|
|
95
97
|
```bash
|
|
96
|
-
storymaps open #
|
|
98
|
+
storymaps open # open local storymap.yml in the browser
|
|
97
99
|
storymaps open storymaps.io/abc123 # open a specific remote map
|
|
98
100
|
```
|
|
99
101
|
|
|
@@ -104,32 +106,37 @@ storymaps pull # uses id/site from storymap.yml
|
|
|
104
106
|
storymaps pull storymaps.io/abc123 # pull a specific map
|
|
105
107
|
```
|
|
106
108
|
|
|
107
|
-
Shows a
|
|
109
|
+
Shows a summary of changes before overwriting.
|
|
108
110
|
|
|
109
111
|
### `push`
|
|
110
112
|
|
|
111
113
|
```bash
|
|
112
|
-
storymaps push
|
|
114
|
+
storymaps push # push local changes to the server
|
|
113
115
|
```
|
|
114
116
|
|
|
115
|
-
If the file has no `id`, a new map is created
|
|
117
|
+
If the file has no `id`, a new map is created on the server. Conflicts are detected using ETags. If the remote has changed since your last pull, you'll be prompted to pull first. Locked maps prompt for the password.
|
|
116
118
|
|
|
117
119
|
### `diff`
|
|
118
120
|
|
|
119
121
|
```bash
|
|
120
|
-
storymaps diff # summary vs remote
|
|
121
|
-
storymaps diff --full # line-by-line diff
|
|
122
|
+
storymaps diff # summary of changes vs remote
|
|
123
|
+
storymaps diff --full # full line-by-line diff
|
|
122
124
|
storymaps diff file1.yml file2.yml # compare two local files
|
|
123
125
|
```
|
|
124
126
|
|
|
125
127
|
### `status`
|
|
126
128
|
|
|
127
|
-
|
|
129
|
+
```bash
|
|
130
|
+
storymaps status # progress overview of storymap.yml
|
|
131
|
+
storymaps status other.yml # progress overview of a specific file
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Progress overview: slices, status counts, and blocked stories.
|
|
128
135
|
|
|
129
136
|
### `lock` / `unlock`
|
|
130
137
|
|
|
131
138
|
```bash
|
|
132
|
-
storymaps lock
|
|
139
|
+
storymaps lock # lock the map with a password
|
|
133
140
|
storymaps unlock # session unlock
|
|
134
141
|
storymaps unlock --remove # permanently remove the lock
|
|
135
142
|
```
|
|
@@ -137,29 +144,16 @@ storymaps unlock --remove # permanently remove the lock
|
|
|
137
144
|
### `validate`
|
|
138
145
|
|
|
139
146
|
```bash
|
|
140
|
-
storymaps validate storymap.yml
|
|
147
|
+
storymaps validate storymap.yml # check YAML structure
|
|
141
148
|
```
|
|
142
149
|
|
|
143
150
|
### `convert`
|
|
144
151
|
|
|
145
152
|
```bash
|
|
146
|
-
storymaps convert storymap.yml --to json
|
|
147
|
-
storymaps convert storymap.json --to yaml --out storymap.yml
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### `import`
|
|
151
|
-
|
|
152
|
-
Import tasks from an external project tracker into a storymap. Currently supports Phabricator.
|
|
153
|
-
|
|
154
|
-
```bash
|
|
155
|
-
storymaps import --from phabricator --tag my-project
|
|
156
|
-
storymaps import --from phab --tag my-project --site localhost
|
|
157
|
-
storymaps import --from phab --tag my-project --status open,in-progress
|
|
153
|
+
storymaps convert storymap.yml --to json # YAML to JSON
|
|
154
|
+
storymaps convert storymap.json --to yaml --out storymap.yml # JSON to YAML
|
|
158
155
|
```
|
|
159
156
|
|
|
160
|
-
Epics (tasks tagged with `Epic` in Phabricator) become steps with their subtasks as cards. Standalone tasks are grouped into backlog columns. The map is registered on the server automatically.
|
|
161
|
-
|
|
162
|
-
On first run you'll be prompted for your Phabricator instance URL and Conduit API token. Credentials are saved to `~/.storymaps/credentials.json`.
|
|
163
157
|
|
|
164
158
|
## YAML Format
|
|
165
159
|
|
|
@@ -266,4 +260,4 @@ See the [main project README](https://github.com/jackgleeson/userstorymaps#self-
|
|
|
266
260
|
|
|
267
261
|
## Licence
|
|
268
262
|
|
|
269
|
-
AGPL-3.0 - see [LICENCE](LICENCE) for details.
|
|
263
|
+
AGPL-3.0 - see [LICENCE](LICENCE) for details.
|
package/bin/storymaps.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
3
3
|
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
import { Command } from 'commander';
|
|
7
|
+
|
|
8
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
6
9
|
import { initCommand } from '../lib/commands/init.js';
|
|
7
10
|
import { validateCommand } from '../lib/commands/validate.js';
|
|
8
11
|
import { convertCommand } from '../lib/commands/convert.js';
|
|
@@ -13,14 +16,14 @@ import { openCommand } from '../lib/commands/open.js';
|
|
|
13
16
|
import { lockCommand } from '../lib/commands/lock.js';
|
|
14
17
|
import { unlockCommand } from '../lib/commands/unlock.js';
|
|
15
18
|
import { statusCommand } from '../lib/commands/status.js';
|
|
16
|
-
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
const program = new Command();
|
|
19
22
|
|
|
20
23
|
program
|
|
21
24
|
.name('storymaps')
|
|
22
25
|
.description('CLI companion for storymaps.io — manage user story maps from the terminal')
|
|
23
|
-
.version(
|
|
26
|
+
.version(pkg.version)
|
|
24
27
|
.showHelpAfterError()
|
|
25
28
|
.addHelpText('after', '\nRun storymaps <command> --help for details on a specific command.');
|
|
26
29
|
|
|
@@ -98,15 +101,6 @@ program
|
|
|
98
101
|
.option('-f, --file <path>', 'path to storymap file', 'storymap.yml')
|
|
99
102
|
.action(statusCommand);
|
|
100
103
|
|
|
101
|
-
program
|
|
102
|
-
.command('import')
|
|
103
|
-
.description('Import tasks from an external tool into a storymap')
|
|
104
|
-
.requiredOption('--from <source>', 'source platform (phabricator)')
|
|
105
|
-
.requiredOption('--tag <slug>', 'project tag/slug to import from')
|
|
106
|
-
.option('--status <statuses>', 'comma-separated statuses to import', 'open,in-progress,stalled,resolved')
|
|
107
|
-
.option('--output <path>', 'output file path', 'storymap.yml')
|
|
108
|
-
.option('-s, --site <site>', 'target site (e.g. localhost, storymaps.io)')
|
|
109
|
-
.action(importCommand);
|
|
110
104
|
|
|
111
105
|
program.parseAsync().catch(err => {
|
|
112
106
|
const message = err.name === 'ExitPromptError' ? 'Cancelled.'
|
package/lib/util/file.js
CHANGED
|
@@ -9,7 +9,6 @@ import { exportToYaml } from '../core/yaml.js';
|
|
|
9
9
|
|
|
10
10
|
const STORYMAPS_DIR = join(homedir(), '.storymaps');
|
|
11
11
|
const CACHE_DIR = join(STORYMAPS_DIR, 'caches');
|
|
12
|
-
const CREDENTIALS_PATH = join(STORYMAPS_DIR, 'credentials.json');
|
|
13
12
|
|
|
14
13
|
function cachePathFor(mapId) {
|
|
15
14
|
return join(CACHE_DIR, `${mapId}.json`);
|
|
@@ -33,24 +32,6 @@ export async function writeCache(mapId, updates) {
|
|
|
33
32
|
await writeFile(cachePathFor(mapId), JSON.stringify(cache), 'utf-8');
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
export async function readCredentials(platform) {
|
|
37
|
-
try {
|
|
38
|
-
const all = JSON.parse(await readFile(CREDENTIALS_PATH, 'utf-8'));
|
|
39
|
-
return all[platform] || null;
|
|
40
|
-
} catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function writeCredentials(platform, credentials) {
|
|
46
|
-
await mkdir(STORYMAPS_DIR, { recursive: true });
|
|
47
|
-
let all = {};
|
|
48
|
-
try {
|
|
49
|
-
all = JSON.parse(await readFile(CREDENTIALS_PATH, 'utf-8'));
|
|
50
|
-
} catch { /* first write */ }
|
|
51
|
-
all[platform] = credentials;
|
|
52
|
-
await writeFile(CREDENTIALS_PATH, JSON.stringify(all, null, 2) + '\n', 'utf-8');
|
|
53
|
-
}
|
|
54
35
|
|
|
55
36
|
export function detectFormat(filePath) {
|
|
56
37
|
const ext = extname(filePath).toLowerCase();
|
package/package.json
CHANGED
package/lib/commands/import.js
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
-
// storymaps import — import tasks from an external tool into a storymap
|
|
3
|
-
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
5
|
-
import { resolve } from 'node:path';
|
|
6
|
-
import chalk from 'chalk';
|
|
7
|
-
import { input, password, confirm } from '@inquirer/prompts';
|
|
8
|
-
import { readCredentials, writeCredentials, writeStorymap } from '../util/file.js';
|
|
9
|
-
import { dumpYaml } from '../core/yaml.js';
|
|
10
|
-
import { postMap } from '../util/api.js';
|
|
11
|
-
import {
|
|
12
|
-
findProject,
|
|
13
|
-
findEpicTagPhid,
|
|
14
|
-
fetchTasks,
|
|
15
|
-
fetchTasksByPhids,
|
|
16
|
-
fetchSubtaskEdges,
|
|
17
|
-
CLI_TO_PHAB_STATUS,
|
|
18
|
-
PHAB_TO_STORYMAP_STATUS,
|
|
19
|
-
} from '../util/phabricator.js';
|
|
20
|
-
|
|
21
|
-
const CARDS_PER_COLUMN = 10;
|
|
22
|
-
|
|
23
|
-
async function promptCredentials(defaultUrl) {
|
|
24
|
-
const url = await input({
|
|
25
|
-
message: 'Phabricator instance URL:',
|
|
26
|
-
default: defaultUrl,
|
|
27
|
-
theme: { prefix: '>' },
|
|
28
|
-
validate: v => v.startsWith('http') || 'Must be a full URL (https://...)',
|
|
29
|
-
});
|
|
30
|
-
console.log();
|
|
31
|
-
console.log(chalk.dim(' To get your API token:'));
|
|
32
|
-
console.log(chalk.dim(' 1. Click your profile picture in Phabricator'));
|
|
33
|
-
console.log(chalk.dim(' 2. Go to Settings'));
|
|
34
|
-
console.log(chalk.dim(' 3. Click "Conduit API Tokens"'));
|
|
35
|
-
console.log(chalk.dim(' 4. Click "Generate Token"'));
|
|
36
|
-
console.log();
|
|
37
|
-
const token = await password({
|
|
38
|
-
message: 'API token:',
|
|
39
|
-
mask: '*',
|
|
40
|
-
theme: { prefix: '>' },
|
|
41
|
-
});
|
|
42
|
-
return { url: url.replace(/\/+$/, ''), token };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function getCredentials() {
|
|
46
|
-
const saved = await readCredentials('phabricator');
|
|
47
|
-
if (saved?.url && saved?.token) {
|
|
48
|
-
console.log(chalk.dim(`Using saved Phabricator credentials (${saved.url})`));
|
|
49
|
-
return saved;
|
|
50
|
-
}
|
|
51
|
-
const creds = await promptCredentials(saved?.url);
|
|
52
|
-
await writeCredentials('phabricator', creds);
|
|
53
|
-
console.log(chalk.dim('Credentials saved to ~/.storymaps/credentials.json'));
|
|
54
|
-
return creds;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function buildYamlObj(projectName, epics, standalones, baseUrl, site) {
|
|
58
|
-
const steps = [];
|
|
59
|
-
const importedStories = {};
|
|
60
|
-
const backlogStories = {};
|
|
61
|
-
|
|
62
|
-
// Epic steps + stories
|
|
63
|
-
for (const epic of epics) {
|
|
64
|
-
const step = { name: epic.name };
|
|
65
|
-
if (epic.description) step.body = epic.description;
|
|
66
|
-
step.url = `${baseUrl}/T${epic.id}`;
|
|
67
|
-
if (epic.points != null) step.points = epic.points;
|
|
68
|
-
steps.push(step);
|
|
69
|
-
importedStories[epic.name] = epic.children.map(task => taskToCard(task, baseUrl));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Backlog columns
|
|
73
|
-
const numColumns = Math.max(1, Math.ceil(standalones.length / CARDS_PER_COLUMN));
|
|
74
|
-
for (let col = 0; col < numColumns; col++) {
|
|
75
|
-
const stepName = String(col + 1);
|
|
76
|
-
steps.push(stepName);
|
|
77
|
-
const chunk = standalones.slice(col * CARDS_PER_COLUMN, (col + 1) * CARDS_PER_COLUMN);
|
|
78
|
-
if (chunk.length) {
|
|
79
|
-
backlogStories[stepName] = chunk.map(task => taskToCard(task, baseUrl));
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const slices = [];
|
|
84
|
-
if (Object.keys(importedStories).length) {
|
|
85
|
-
slices.push({ name: 'Imported', stories: importedStories });
|
|
86
|
-
}
|
|
87
|
-
if (Object.keys(backlogStories).length) {
|
|
88
|
-
slices.push({ name: 'Backlog', stories: backlogStories });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const obj = { name: projectName, id: undefined, site, steps, slices };
|
|
92
|
-
return obj;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function taskToCard(task, baseUrl) {
|
|
96
|
-
const card = { name: task.name };
|
|
97
|
-
const status = PHAB_TO_STORYMAP_STATUS[task.status];
|
|
98
|
-
if (status) card.status = status;
|
|
99
|
-
if (task.description) card.body = task.description;
|
|
100
|
-
if (task.points != null) card.points = task.points;
|
|
101
|
-
card.url = `${baseUrl}/T${task.id}`;
|
|
102
|
-
return card;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export async function importCommand(opts) {
|
|
106
|
-
const source = opts.from === 'phab' ? 'phabricator' : opts.from;
|
|
107
|
-
if (source !== 'phabricator') {
|
|
108
|
-
console.error(chalk.red(`Unsupported source: ${source}. Supported: phabricator (or phab)`));
|
|
109
|
-
process.exit(1);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const tag = opts.tag;
|
|
113
|
-
if (!tag) {
|
|
114
|
-
console.error(chalk.red('--tag is required'));
|
|
115
|
-
process.exit(1);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const outputPath = resolve(opts.output || 'storymap.yml');
|
|
119
|
-
const statusList = (opts.status || 'open,in-progress,stalled,resolved').split(',').map(s => s.trim());
|
|
120
|
-
|
|
121
|
-
// Validate status names
|
|
122
|
-
for (const s of statusList) {
|
|
123
|
-
if (!CLI_TO_PHAB_STATUS[s]) {
|
|
124
|
-
console.error(chalk.red(`Unknown status: ${s}. Valid: ${Object.keys(CLI_TO_PHAB_STATUS).join(', ')}`));
|
|
125
|
-
process.exit(1);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const phabStatuses = statusList.map(s => CLI_TO_PHAB_STATUS[s]);
|
|
130
|
-
|
|
131
|
-
// Credentials
|
|
132
|
-
let { url: baseUrl, token } = await getCredentials();
|
|
133
|
-
|
|
134
|
-
// Resolve project (retry on auth error)
|
|
135
|
-
console.log(chalk.dim(`Looking up project "${tag}"...`));
|
|
136
|
-
let project;
|
|
137
|
-
try {
|
|
138
|
-
project = await findProject(baseUrl, token, tag);
|
|
139
|
-
} catch (err) {
|
|
140
|
-
if (err.message.includes('ERR-INVALID-AUTH') || err.message.includes('ERR-CONDUIT-CORE')) {
|
|
141
|
-
console.error(chalk.red(err.message));
|
|
142
|
-
console.log();
|
|
143
|
-
const creds = await promptCredentials(baseUrl);
|
|
144
|
-
baseUrl = creds.url;
|
|
145
|
-
token = creds.token;
|
|
146
|
-
try {
|
|
147
|
-
project = await findProject(baseUrl, token, tag);
|
|
148
|
-
await writeCredentials('phabricator', creds);
|
|
149
|
-
console.log(chalk.dim('Credentials saved to ~/.storymaps/credentials.json'));
|
|
150
|
-
} catch (retryErr) {
|
|
151
|
-
console.error(chalk.red(retryErr.message));
|
|
152
|
-
process.exit(1);
|
|
153
|
-
}
|
|
154
|
-
} else {
|
|
155
|
-
console.error(chalk.red(err.message));
|
|
156
|
-
process.exit(1);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
console.log(`Project: ${chalk.cyan(project.name)}`);
|
|
160
|
-
|
|
161
|
-
// Fetch tasks and resolve epic tag
|
|
162
|
-
console.log(chalk.dim('Fetching tasks...'));
|
|
163
|
-
let tasks, epicPhid;
|
|
164
|
-
try {
|
|
165
|
-
[tasks, epicPhid] = await Promise.all([
|
|
166
|
-
fetchTasks(baseUrl, token, project.phid, phabStatuses),
|
|
167
|
-
findEpicTagPhid(baseUrl, token),
|
|
168
|
-
]);
|
|
169
|
-
} catch (err) {
|
|
170
|
-
console.error(chalk.red(`Failed to fetch tasks: ${err.message}`));
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (!tasks.length) {
|
|
175
|
-
console.log(chalk.yellow('No tasks found.'));
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Identify epics by the Epic project tag
|
|
180
|
-
const epicTasks = epicPhid ? tasks.filter(t => t.projectPhids.includes(epicPhid)) : [];
|
|
181
|
-
const epicPhids = new Set(epicTasks.map(t => t.phid));
|
|
182
|
-
|
|
183
|
-
// Fetch subtasks for epics
|
|
184
|
-
let edges = [];
|
|
185
|
-
if (epicTasks.length) {
|
|
186
|
-
console.log(chalk.dim('Fetching subtasks...'));
|
|
187
|
-
try {
|
|
188
|
-
edges = await fetchSubtaskEdges(baseUrl, token, epicTasks.map(t => t.phid));
|
|
189
|
-
} catch (err) {
|
|
190
|
-
console.error(chalk.red(`Failed to fetch subtasks: ${err.message}`));
|
|
191
|
-
process.exit(1);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Build epic → children map, fetching any subtasks not in the project
|
|
196
|
-
const taskByPhid = new Map(tasks.map(t => [t.phid, t]));
|
|
197
|
-
const childPhids = new Set(edges.map(e => e.destinationPHID));
|
|
198
|
-
const missingPhids = [...childPhids].filter(phid => !taskByPhid.has(phid));
|
|
199
|
-
if (missingPhids.length) {
|
|
200
|
-
const missing = await fetchTasksByPhids(baseUrl, token, missingPhids);
|
|
201
|
-
for (const t of missing) taskByPhid.set(t.phid, t);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const epics = epicTasks.map(epic => {
|
|
205
|
-
const childTaskPhids = edges
|
|
206
|
-
.filter(e => e.sourcePHID === epic.phid)
|
|
207
|
-
.map(e => e.destinationPHID);
|
|
208
|
-
const children = childTaskPhids.map(phid => taskByPhid.get(phid)).filter(Boolean);
|
|
209
|
-
return { ...epic, children };
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// Standalone = not an epic, not a child of an epic
|
|
213
|
-
const standalones = tasks.filter(t => !epicPhids.has(t.phid) && !childPhids.has(t.phid));
|
|
214
|
-
|
|
215
|
-
const subtaskCount = epics.reduce((sum, e) => sum + e.children.length, 0);
|
|
216
|
-
console.log(` ${epics.length} epics, ${subtaskCount} subtasks`);
|
|
217
|
-
console.log(` ${standalones.length} standalone tasks`);
|
|
218
|
-
|
|
219
|
-
// Build YAML and register on storymaps server
|
|
220
|
-
const site = opts.site || 'storymaps.io';
|
|
221
|
-
const siteBaseUrl = site === 'localhost' ? 'http://localhost:3000' : `https://${site}`;
|
|
222
|
-
const yamlObj = buildYamlObj(project.name, epics, standalones, baseUrl, site);
|
|
223
|
-
let yamlString = dumpYaml(yamlObj);
|
|
224
|
-
try {
|
|
225
|
-
const { id } = await postMap(siteBaseUrl, yamlString, 'text/yaml');
|
|
226
|
-
yamlObj.id = id;
|
|
227
|
-
yamlString = dumpYaml(yamlObj);
|
|
228
|
-
console.log(chalk.dim(` Registered on ${site} (id: ${id})`));
|
|
229
|
-
} catch {
|
|
230
|
-
console.log(chalk.dim(` Could not reach ${site} - run "storymaps push" later to register`));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Write
|
|
234
|
-
if (existsSync(outputPath)) {
|
|
235
|
-
const overwrite = await confirm({
|
|
236
|
-
message: `${outputPath} already exists. Overwrite?`,
|
|
237
|
-
default: false,
|
|
238
|
-
theme: { prefix: '>' },
|
|
239
|
-
});
|
|
240
|
-
if (!overwrite) {
|
|
241
|
-
console.log('Aborted.');
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
await writeStorymap(outputPath, yamlString);
|
|
247
|
-
console.log(chalk.green(`Wrote ${outputPath}`));
|
|
248
|
-
console.log(chalk.dim(` ${tasks.length} tasks, ${epics.length} epics, ${standalones.length} standalone`));
|
|
249
|
-
}
|
package/lib/util/phabricator.js
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
// Storymaps CLI — AGPL-3.0 — see LICENCE for details
|
|
2
|
-
// Phabricator Conduit API client
|
|
3
|
-
|
|
4
|
-
// CLI status name → Phabricator Conduit status value
|
|
5
|
-
export const CLI_TO_PHAB_STATUS = {
|
|
6
|
-
'open': 'open',
|
|
7
|
-
'in-progress': 'progress',
|
|
8
|
-
'stalled': 'stalled',
|
|
9
|
-
'resolved': 'resolved',
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
// Phabricator Conduit status value → storymap YAML status
|
|
13
|
-
export const PHAB_TO_STORYMAP_STATUS = {
|
|
14
|
-
'open': 'planned',
|
|
15
|
-
'progress': 'in-progress',
|
|
16
|
-
'stalled': 'blocked',
|
|
17
|
-
'resolved': 'done',
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
async function conduit(baseUrl, method, token, params = {}) {
|
|
21
|
-
const url = `${baseUrl.replace(/\/+$/, '')}/api/${method}`;
|
|
22
|
-
const args = ['curl', '-s', '-X', 'POST', url, '-d', `api.token=${token}`];
|
|
23
|
-
for (const [key, value] of Object.entries(params)) {
|
|
24
|
-
args.push('-d', `${key}=${value}`);
|
|
25
|
-
}
|
|
26
|
-
const { execFile } = await import('node:child_process');
|
|
27
|
-
const { promisify } = await import('node:util');
|
|
28
|
-
const exec = promisify(execFile);
|
|
29
|
-
const { stdout } = await exec(args[0], args.slice(1));
|
|
30
|
-
const json = JSON.parse(stdout);
|
|
31
|
-
if (json.error_code) {
|
|
32
|
-
throw new Error(`Conduit ${method}: [${json.error_code}] ${json.error_info}`);
|
|
33
|
-
}
|
|
34
|
-
return json.result;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Resolve a project slug to its PHID and name.
|
|
39
|
-
*/
|
|
40
|
-
export async function findProject(baseUrl, token, slug) {
|
|
41
|
-
const result = await conduit(baseUrl, 'project.search', token, {
|
|
42
|
-
'constraints[slugs][0]': slug,
|
|
43
|
-
});
|
|
44
|
-
const project = result.data?.[0];
|
|
45
|
-
if (!project) {
|
|
46
|
-
throw new Error(`Project not found: ${slug}`);
|
|
47
|
-
}
|
|
48
|
-
return { phid: project.phid, name: project.fields.name };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Find the PHID of the "Epic" project tag. Returns null if not found.
|
|
53
|
-
*/
|
|
54
|
-
export async function findEpicTagPhid(baseUrl, token) {
|
|
55
|
-
const result = await conduit(baseUrl, 'project.search', token, {
|
|
56
|
-
'constraints[slugs][0]': 'epic',
|
|
57
|
-
});
|
|
58
|
-
return result.data?.[0]?.phid || null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Fetch all tasks in a project matching the given statuses.
|
|
63
|
-
* Handles cursor-based pagination. Calls onPage(tasks, total) per page.
|
|
64
|
-
*/
|
|
65
|
-
export async function fetchTasks(baseUrl, token, projectPhid, statuses, { onPage } = {}) {
|
|
66
|
-
const all = [];
|
|
67
|
-
let after = null;
|
|
68
|
-
for (;;) {
|
|
69
|
-
const params = {
|
|
70
|
-
'constraints[projects][0]': projectPhid,
|
|
71
|
-
'attachments[projects]': '1',
|
|
72
|
-
'order': 'newest',
|
|
73
|
-
};
|
|
74
|
-
statuses.forEach((s, i) => {
|
|
75
|
-
params[`constraints[statuses][${i}]`] = s;
|
|
76
|
-
});
|
|
77
|
-
if (after) params['after'] = after;
|
|
78
|
-
|
|
79
|
-
const result = await conduit(baseUrl, 'maniphest.search', token, params);
|
|
80
|
-
const tasks = (result.data || []).map(t => ({
|
|
81
|
-
id: t.id,
|
|
82
|
-
phid: t.phid,
|
|
83
|
-
name: t.fields.name,
|
|
84
|
-
description: t.fields.description?.raw || '',
|
|
85
|
-
status: t.fields.status?.value || 'open',
|
|
86
|
-
points: t.fields.points != null ? Number(t.fields.points) : null,
|
|
87
|
-
projectPhids: t.attachments?.projects?.projectPHIDs || [],
|
|
88
|
-
}));
|
|
89
|
-
all.push(...tasks);
|
|
90
|
-
if (onPage) onPage(tasks, all.length);
|
|
91
|
-
|
|
92
|
-
after = result.cursor?.after;
|
|
93
|
-
if (!after) break;
|
|
94
|
-
}
|
|
95
|
-
return all;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Fetch tasks by their PHIDs. Returns the same shape as fetchTasks.
|
|
100
|
-
*/
|
|
101
|
-
export async function fetchTasksByPhids(baseUrl, token, phids) {
|
|
102
|
-
const BATCH = 100;
|
|
103
|
-
const all = [];
|
|
104
|
-
for (let i = 0; i < phids.length; i += BATCH) {
|
|
105
|
-
const batch = phids.slice(i, i + BATCH);
|
|
106
|
-
const params = {};
|
|
107
|
-
batch.forEach((phid, j) => {
|
|
108
|
-
params[`constraints[phids][${j}]`] = phid;
|
|
109
|
-
});
|
|
110
|
-
const result = await conduit(baseUrl, 'maniphest.search', token, params);
|
|
111
|
-
const tasks = (result.data || []).map(t => ({
|
|
112
|
-
id: t.id,
|
|
113
|
-
phid: t.phid,
|
|
114
|
-
name: t.fields.name,
|
|
115
|
-
description: t.fields.description?.raw || '',
|
|
116
|
-
status: t.fields.status?.value || 'open',
|
|
117
|
-
points: t.fields.points != null ? Number(t.fields.points) : null,
|
|
118
|
-
projectPhids: [],
|
|
119
|
-
}));
|
|
120
|
-
all.push(...tasks);
|
|
121
|
-
}
|
|
122
|
-
return all;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Fetch subtask edges for a set of task PHIDs.
|
|
127
|
-
* Returns [{ sourcePHID, destinationPHID }] where source is parent, destination is child.
|
|
128
|
-
* Handles pagination and batches in chunks of 100.
|
|
129
|
-
*/
|
|
130
|
-
export async function fetchSubtaskEdges(baseUrl, token, taskPhids) {
|
|
131
|
-
const BATCH = 100;
|
|
132
|
-
const all = [];
|
|
133
|
-
for (let i = 0; i < taskPhids.length; i += BATCH) {
|
|
134
|
-
const batch = taskPhids.slice(i, i + BATCH);
|
|
135
|
-
let after = null;
|
|
136
|
-
for (;;) {
|
|
137
|
-
const params = { 'types[0]': 'task.subtask' };
|
|
138
|
-
batch.forEach((phid, j) => {
|
|
139
|
-
params[`sourcePHIDs[${j}]`] = phid;
|
|
140
|
-
});
|
|
141
|
-
if (after) params['after'] = after;
|
|
142
|
-
|
|
143
|
-
const result = await conduit(baseUrl, 'edge.search', token, params);
|
|
144
|
-
const edges = (result.data || []).map(e => ({
|
|
145
|
-
sourcePHID: e.sourcePHID,
|
|
146
|
-
destinationPHID: e.destinationPHID,
|
|
147
|
-
}));
|
|
148
|
-
all.push(...edges);
|
|
149
|
-
|
|
150
|
-
after = result.cursor?.after;
|
|
151
|
-
if (!after) break;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return all;
|
|
155
|
-
}
|