storymaps 1.0.0 → 1.1.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 +15 -1
- package/bin/storymaps.js +11 -0
- package/lib/commands/import.js +249 -0
- package/lib/commands/open.js +1 -1
- package/lib/core/yaml.js +16 -3
- package/lib/util/file.js +22 -1
- package/lib/util/phabricator.js +155 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,10 +143,24 @@ storymaps validate storymap.yml
|
|
|
143
143
|
### `convert`
|
|
144
144
|
|
|
145
145
|
```bash
|
|
146
|
-
storymaps convert storymap.yml --to json
|
|
146
|
+
storymaps convert storymap.yml --to json
|
|
147
147
|
storymaps convert storymap.json --to yaml --out storymap.yml
|
|
148
148
|
```
|
|
149
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
|
|
158
|
+
```
|
|
159
|
+
|
|
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
|
+
|
|
150
164
|
## YAML Format
|
|
151
165
|
|
|
152
166
|
```yaml
|
package/bin/storymaps.js
CHANGED
|
@@ -13,6 +13,7 @@ import { openCommand } from '../lib/commands/open.js';
|
|
|
13
13
|
import { lockCommand } from '../lib/commands/lock.js';
|
|
14
14
|
import { unlockCommand } from '../lib/commands/unlock.js';
|
|
15
15
|
import { statusCommand } from '../lib/commands/status.js';
|
|
16
|
+
import { importCommand } from '../lib/commands/import.js';
|
|
16
17
|
|
|
17
18
|
const program = new Command();
|
|
18
19
|
|
|
@@ -97,6 +98,16 @@ program
|
|
|
97
98
|
.option('-f, --file <path>', 'path to storymap file', 'storymap.yml')
|
|
98
99
|
.action(statusCommand);
|
|
99
100
|
|
|
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
|
+
|
|
100
111
|
program.parseAsync().catch(err => {
|
|
101
112
|
const message = err.name === 'ExitPromptError' ? 'Cancelled.'
|
|
102
113
|
: err.message || String(err);
|
|
@@ -0,0 +1,249 @@
|
|
|
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/commands/open.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function openCommand(target, opts) {
|
|
|
12
12
|
try {
|
|
13
13
|
({ id, baseUrl } = await resolveTarget(target, filePath));
|
|
14
14
|
} catch {
|
|
15
|
-
console.error(chalk.red('No map ID found. Run "storymaps
|
|
15
|
+
console.error(chalk.red('No map ID found. Run "storymaps push" to create it on the server, or pass one directly: storymaps open <id>'));
|
|
16
16
|
process.exitCode = 1;
|
|
17
17
|
return;
|
|
18
18
|
}
|
package/lib/core/yaml.js
CHANGED
|
@@ -174,8 +174,15 @@ export function jsonToYamlObj(data) {
|
|
|
174
174
|
if (stepTypes[i] === 'spacer') return 'spacer';
|
|
175
175
|
if (stepTypes[i] === 'partial') return { partial: partialSlugMap[step.partialMapId] };
|
|
176
176
|
const name = resolvedNames[i];
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
const hasExtra = (step.color && step.color !== DEFAULT_STEP_COLOR)
|
|
178
|
+
|| step.url || step.body || step.points != null;
|
|
179
|
+
if (!hasExtra) return name;
|
|
180
|
+
const obj = { name };
|
|
181
|
+
if (step.color && step.color !== DEFAULT_STEP_COLOR) obj.color = step.color;
|
|
182
|
+
if (step.body) obj.body = step.body;
|
|
183
|
+
if (step.url) obj.url = step.url;
|
|
184
|
+
if (step.points != null) obj.points = step.points;
|
|
185
|
+
return obj;
|
|
179
186
|
});
|
|
180
187
|
|
|
181
188
|
// Legend
|
|
@@ -321,7 +328,13 @@ export function yamlObjToJson(obj) {
|
|
|
321
328
|
const displayName = rawName.replace(/:\[\d+\]\s*/, ': ').replace(/\[\d+\]$/, '');
|
|
322
329
|
const color = typeof step === 'string' ? DEFAULT_STEP_COLOR : (step.color || DEFAULT_STEP_COLOR);
|
|
323
330
|
stepNameToIndex[rawName] = jsonSteps.length;
|
|
324
|
-
|
|
331
|
+
const s = { name: displayName, color };
|
|
332
|
+
if (typeof step === 'object' && step !== null) {
|
|
333
|
+
if (step.url) s.url = step.url;
|
|
334
|
+
if (step.body) s.body = step.body;
|
|
335
|
+
if (step.points != null) s.points = step.points;
|
|
336
|
+
}
|
|
337
|
+
jsonSteps.push(s);
|
|
325
338
|
});
|
|
326
339
|
|
|
327
340
|
const n = jsonSteps.length;
|
package/lib/util/file.js
CHANGED
|
@@ -7,7 +7,9 @@ import { dirname, basename, join, extname } from 'node:path';
|
|
|
7
7
|
import jsyaml from 'js-yaml';
|
|
8
8
|
import { exportToYaml } from '../core/yaml.js';
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const STORYMAPS_DIR = join(homedir(), '.storymaps');
|
|
11
|
+
const CACHE_DIR = join(STORYMAPS_DIR, 'caches');
|
|
12
|
+
const CREDENTIALS_PATH = join(STORYMAPS_DIR, 'credentials.json');
|
|
11
13
|
|
|
12
14
|
function cachePathFor(mapId) {
|
|
13
15
|
return join(CACHE_DIR, `${mapId}.json`);
|
|
@@ -31,6 +33,25 @@ export async function writeCache(mapId, updates) {
|
|
|
31
33
|
await writeFile(cachePathFor(mapId), JSON.stringify(cache), 'utf-8');
|
|
32
34
|
}
|
|
33
35
|
|
|
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
|
+
|
|
34
55
|
export function detectFormat(filePath) {
|
|
35
56
|
const ext = extname(filePath).toLowerCase();
|
|
36
57
|
if (ext === '.json') return 'json';
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
}
|