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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Storymaps CLI
2
2
 
3
- The Storymaps CLI turns your storymaps.io map diagrams into YAML files you can version-control in git, diff in pull requests, and sync with the [storymaps.io](https://storymaps.io) server or your self-hosted instance all from your terminal. Built by developers for developers.
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 aren't just cards on a screen, they're structured data. The CLI lets you import the structure behind the diagram, edit it as code, and convert back and forth between your repo and the browser.
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 # view it in the browser
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 in your editor, then push it back:
48
+ Edit the YAML, then push it back:
49
49
 
50
50
  ```bash
51
51
  storymaps diff # see what changed
52
- storymaps push # upload to the server
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 stats counts based on the legend.
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 # opens local storymap.yml in the web UI to work with
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 change summary before overwriting.
109
+ Shows a summary of changes before overwriting.
108
110
 
109
111
  ### `push`
110
112
 
111
113
  ```bash
112
- storymaps push # push local changes to the server
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 automatically. Conflicts are detected using ETags - if the remote changed since your last pull, you'll be prompted to pull first. Locked maps prompt for the password.
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
- Progress overview - slices, status counts, blocked stories.
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
- import { importCommand } from '../lib/commands/import.js';
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('0.0.1')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymaps",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "CLI tool for storymaps.io - import, export, validate, and manage user story maps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- }
@@ -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
- }