ekohacks 0.6.0 → 0.8.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 CHANGED
@@ -12,6 +12,8 @@ The second command is `ekohacks docs check`, specified in [`stories/6-docs-check
12
12
 
13
13
  `ekohacks docs sync` is the other half, specified in [`stories/8-docs-sync.md`](stories/8-docs-sync.md): it writes the edits the check can prove — the entry-point block, the prose counts — and scaffolds a stub page for an entry point the docs just gained, leaving the prose to a person. [`stories/9-docs-draft.md`](stories/9-docs-draft.md) specifies drafting that prose and opening a PR for a human to review; it is written down, not built.
14
14
 
15
+ The third command is `ekohacks stories`, specified in stories [11](stories/11-stories-fetch.md)–[13](stories/13-stories-create.md): the Linear board as a surface. `fetch` reads a whole column across pages, `archive` empties it once it has been read (reversible, batched, safe to re-run), and `create` pushes a new project's backlog from a JSON file with a `--dry-run` preview. It grew from the dojo's story-audit workshop scripts and the propi-o backlog push, and needs `LINEAR_API_KEY` in the environment. A mistyped team or column is a named stop rather than an empty result, specified in [story 14](stories/14-stories-named-stops.md) after user testing hit the ambiguity twice in an hour, and `fetch` with just the team prints the whole board, column by column ([story 15](stories/15-stories-overview.md)).
16
+
15
17
  ## How this is built
16
18
 
17
19
  - A tested core policy behind nullable infrastructure, in the style of [James Shore's Testing Without Mocks](https://www.jamesshore.com/v2/projects/nullables): real wrappers around `git`, `gh` and `npm`, each with a `createNull()` that answers with configurable responses and records what was asked of it. No mocks, no spies.
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { dirname } from 'node:path';
7
7
  import { createInterface } from 'node:readline/promises';
8
8
  import { GhWrapper } from './infrastructure/gh.js';
9
9
  import { GitWrapper } from './infrastructure/git.js';
10
+ import { LinearWrapper } from './infrastructure/linear.js';
10
11
  import { NpmWrapper } from './infrastructure/npm.js';
11
12
  import { ProcessRunner } from './infrastructure/process.js';
12
13
  import { ClaudeWrapper } from './infrastructure/claude.js';
@@ -15,11 +16,15 @@ import { docsCheck, docsDraft, docsSync, draftBranchConflict, draftPrompts, open
15
16
  import { preflight } from './logic/preflight.js';
16
17
  import { release } from './logic/release.js';
17
18
  import { ship } from './logic/ship.js';
19
+ import { storiesArchive, storiesCreate, storiesFetch, storiesOverview, storyLine, } from './logic/stories.js';
18
20
  const USAGE = [
19
21
  'usage: ekohacks release [preflight|cut|ship] <version> [--yes]',
20
22
  ' ekohacks docs check',
21
23
  ' ekohacks docs sync [--dry-run]',
22
24
  ' ekohacks docs draft [--yes]',
25
+ ' ekohacks stories fetch <team> [<column> [out.json]]',
26
+ ' ekohacks stories archive <team> <column> [--yes]',
27
+ ' ekohacks stories create <team> <backlog.json> [--yes] [--dry-run]',
23
28
  ].join('\n');
24
29
  const FLAGS = ['--yes', '--dry-run'];
25
30
  const argv = process.argv.slice(2);
@@ -31,6 +36,13 @@ const printChecks = (checks) => {
31
36
  console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
32
37
  }
33
38
  };
39
+ const confirm = async (question) => {
40
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
41
+ const answer = await readline.question(`${question} (y/n) `);
42
+ readline.close();
43
+ return answer.trim().toLowerCase() === 'y';
44
+ };
45
+ const narrate = (line) => console.log(` ${line}`);
34
46
  if (command === 'docs') {
35
47
  const subject = rest[0];
36
48
  if (rest.length !== 1 || (subject !== 'check' && subject !== 'sync' && subject !== 'draft')) {
@@ -140,6 +152,106 @@ if (command === 'docs') {
140
152
  printChecks(report.checks);
141
153
  process.exit(report.checks.every((check) => check.passed) ? 0 : 1);
142
154
  }
155
+ if (command === 'stories') {
156
+ const subject = rest[0];
157
+ const team = rest[1];
158
+ const target = rest[2];
159
+ const shapeOk = team !== undefined &&
160
+ ((subject === 'fetch' && rest.length <= 4) ||
161
+ (target !== undefined &&
162
+ ((subject === 'archive' && rest.length === 3) ||
163
+ (subject === 'create' && rest.length === 3))));
164
+ if (!shapeOk) {
165
+ console.error(USAGE);
166
+ process.exit(2);
167
+ }
168
+ if (process.env.LINEAR_API_KEY === undefined || process.env.LINEAR_API_KEY === '') {
169
+ console.error(`stopped: stories ${subject} needs LINEAR_API_KEY`);
170
+ process.exit(1);
171
+ }
172
+ const linear = LinearWrapper.create();
173
+ const approve = yes ? () => Promise.resolve(true) : confirm;
174
+ try {
175
+ if (subject === 'fetch' && target === undefined) {
176
+ const result = await storiesOverview({ team, linear, narrate });
177
+ if ('stopped' in result) {
178
+ console.error(`stopped: ${result.stopped}`);
179
+ process.exit(1);
180
+ }
181
+ for (const { column, count } of result.board) {
182
+ console.log(`${column}: ${count}`);
183
+ }
184
+ process.exit(0);
185
+ }
186
+ if (subject === 'fetch' && target !== undefined) {
187
+ const out = rest[3];
188
+ const result = await storiesFetch({ team, column: target, linear, narrate });
189
+ if ('stopped' in result) {
190
+ console.error(`stopped: ${result.stopped}`);
191
+ process.exit(1);
192
+ }
193
+ for (const story of result.stories) {
194
+ console.log(storyLine(story));
195
+ }
196
+ if (out !== undefined) {
197
+ writeFileSync(out, `${JSON.stringify(result.stories, null, 2)}\n`);
198
+ narrate(`${result.stories.length} stories written to ${out}`);
199
+ }
200
+ process.exit(0);
201
+ }
202
+ if (target === undefined) {
203
+ console.error(USAGE);
204
+ process.exit(2);
205
+ }
206
+ if (subject === 'archive') {
207
+ const result = await storiesArchive({
208
+ team,
209
+ column: target,
210
+ linear,
211
+ confirm: approve,
212
+ narrate,
213
+ });
214
+ if ('stopped' in result) {
215
+ console.error(`stopped: ${result.stopped}`);
216
+ process.exit(1);
217
+ }
218
+ process.exit(0);
219
+ }
220
+ if (!existsSync(target)) {
221
+ console.error(`stopped: no ${target} in this directory`);
222
+ process.exit(1);
223
+ }
224
+ let backlog;
225
+ try {
226
+ const parsed = JSON.parse(readFileSync(target, 'utf8'));
227
+ if (!Array.isArray(parsed)) {
228
+ throw new Error('not a list');
229
+ }
230
+ backlog = parsed;
231
+ }
232
+ catch {
233
+ console.error(`stopped: ${target} is not a JSON list of stories`);
234
+ process.exit(1);
235
+ }
236
+ const result = await storiesCreate({
237
+ team,
238
+ backlog,
239
+ linear,
240
+ confirm: approve,
241
+ narrate,
242
+ dryRun,
243
+ });
244
+ if ('stopped' in result) {
245
+ console.error(`stopped: ${result.stopped}`);
246
+ process.exit(1);
247
+ }
248
+ process.exit(0);
249
+ }
250
+ catch (error) {
251
+ console.error(`stopped: ${error.message}`);
252
+ process.exit(1);
253
+ }
254
+ }
143
255
  const first = rest[0];
144
256
  const subcommand = first === 'preflight' || first === 'cut' || first === 'ship' ? first : undefined;
145
257
  const version = subcommand === undefined ? first : rest[1];
@@ -157,13 +269,6 @@ const read = (file) => readFileSync(file, 'utf8');
157
269
  const changelog = read('CHANGELOG.md');
158
270
  const manifest = JSON.parse(read('package.json'));
159
271
  const pkg = manifest.name;
160
- const confirm = async (question) => {
161
- const readline = createInterface({ input: process.stdin, output: process.stdout });
162
- const answer = await readline.question(`${question} (y/n) `);
163
- readline.close();
164
- return answer.trim().toLowerCase() === 'y';
165
- };
166
- const narrate = (line) => console.log(` ${line}`);
167
272
  if (subcommand === undefined) {
168
273
  const result = await release({
169
274
  version,
@@ -0,0 +1,222 @@
1
+ const API = 'https://api.linear.app/graphql';
2
+ const STORIES_PAGE = `query StoriesPage($team: String!, $column: String!, $after: String) {
3
+ issues(
4
+ filter: { team: { key: { eq: $team } }, state: { name: { eq: $column } } }
5
+ first: 100
6
+ after: $after
7
+ orderBy: updatedAt
8
+ ) {
9
+ pageInfo { hasNextPage endCursor }
10
+ nodes { identifier title description completedAt estimate labels { nodes { name } } }
11
+ }
12
+ }`;
13
+ // No cursor here on purpose: archived stories drop out of default query results, so the
14
+ // archive loop re-fetches this first page until it comes back empty.
15
+ const STORY_IDS = `query StoryIds($team: String!, $column: String!) {
16
+ issues(
17
+ filter: { team: { key: { eq: $team } }, state: { name: { eq: $column } } }
18
+ first: 100
19
+ ) {
20
+ nodes { id identifier }
21
+ }
22
+ }`;
23
+ const TEAM = `query Team($key: String!) {
24
+ teams(filter: { key: { eq: $key } }) { nodes { id states { nodes { name position } } } }
25
+ }`;
26
+ const BOARD_PAGE = `query BoardPage($team: String!, $after: String) {
27
+ issues(
28
+ filter: { team: { key: { eq: $team } } }
29
+ first: 100
30
+ after: $after
31
+ orderBy: updatedAt
32
+ ) {
33
+ pageInfo { hasNextPage endCursor }
34
+ nodes { identifier state { name } }
35
+ }
36
+ }`;
37
+ const CREATE_STORY = `mutation CreateStory($input: IssueCreateInput!) {
38
+ issueCreate(input: $input) { issue { id identifier url } }
39
+ }`;
40
+ // Wraps the Linear surface the stories commands need, behind the one GraphQL endpoint.
41
+ // Real and null share every line above the bottom layer: create() posts to api.linear.app
42
+ // with the key from the environment, createNull() answers GraphQL-shaped envelopes from
43
+ // configured state and never opens a socket; the real side is proven against a real board
44
+ // rather than a faked Linear. idRounds configures successive answers to storyIds(): each
45
+ // call takes the next round and the last round repeats, so a test can walk a column down
46
+ // to empty. archiveRounds configures how many archives succeed per call; beyond the list,
47
+ // all of them do.
48
+ export class LinearWrapper {
49
+ static create({ apiKey = process.env.LINEAR_API_KEY ?? '', } = {}) {
50
+ return new LinearWrapper(async (body) => {
51
+ const response = await fetch(API, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json', Authorization: apiKey },
54
+ body: JSON.stringify(body),
55
+ });
56
+ if (!response.ok) {
57
+ throw new Error(`linear answered ${response.status}: ${await response.text()}`);
58
+ }
59
+ return (await response.json());
60
+ });
61
+ }
62
+ static createNull({ pages = [[]], board = [[]], idRounds = [[]], archiveRounds = [], teams = {}, } = {}) {
63
+ let idRound = 0;
64
+ let archiveRound = 0;
65
+ let minted = 0;
66
+ return new LinearWrapper((body) => {
67
+ const answer = (data) => Promise.resolve({ data });
68
+ if (body.query.includes('issueCreate')) {
69
+ minted += 1;
70
+ const issue = {
71
+ id: `null-issue-${minted}`,
72
+ identifier: `NULL-${minted}`,
73
+ url: `https://linear.app/nulled/issue/NULL-${minted}`,
74
+ };
75
+ return answer({ issueCreate: { issue } });
76
+ }
77
+ if (body.query.includes('issueArchive')) {
78
+ const calls = body.query.match(/issueArchive/g) ?? [];
79
+ const index = archiveRound;
80
+ archiveRound += 1;
81
+ const successes = archiveRounds[index] ?? calls.length;
82
+ return answer(Object.fromEntries(calls.map((_, i) => [`a${i}`, { success: i < successes }])));
83
+ }
84
+ if (body.query.includes('teams(')) {
85
+ const team = teams[body.variables?.key];
86
+ return answer({
87
+ teams: {
88
+ nodes: team === undefined
89
+ ? []
90
+ : [
91
+ {
92
+ id: team.id,
93
+ states: {
94
+ nodes: team.columns.map((name, position) => ({ name, position })),
95
+ },
96
+ },
97
+ ],
98
+ },
99
+ });
100
+ }
101
+ if (body.query.includes('BoardPage')) {
102
+ const after = body.variables?.after;
103
+ const index = after === null ? 0 : Number(after.replace('cursor-', ''));
104
+ const hasNextPage = index < board.length - 1;
105
+ return answer({
106
+ issues: {
107
+ pageInfo: { hasNextPage, endCursor: hasNextPage ? `cursor-${index + 1}` : null },
108
+ nodes: (board[index] ?? []).map((row) => ({
109
+ identifier: row.identifier,
110
+ state: { name: row.column },
111
+ })),
112
+ },
113
+ });
114
+ }
115
+ if (body.query.includes('pageInfo')) {
116
+ const after = body.variables?.after;
117
+ const index = after === null ? 0 : Number(after.replace('cursor-', ''));
118
+ const hasNextPage = index < pages.length - 1;
119
+ return answer({
120
+ issues: {
121
+ pageInfo: { hasNextPage, endCursor: hasNextPage ? `cursor-${index + 1}` : null },
122
+ nodes: (pages[index] ?? []).map((story) => ({
123
+ ...story,
124
+ labels: { nodes: story.labels.map((name) => ({ name })) },
125
+ })),
126
+ },
127
+ });
128
+ }
129
+ const round = Math.min(idRound, idRounds.length - 1);
130
+ idRound += 1;
131
+ return answer({ issues: { nodes: idRounds[round] ?? [] } });
132
+ });
133
+ }
134
+ post;
135
+ constructor(post) {
136
+ this.post = post;
137
+ }
138
+ async query(query, variables) {
139
+ const envelope = await this.post({ query, variables });
140
+ if (envelope.errors !== undefined && envelope.errors.length > 0) {
141
+ throw new Error(`linear answered: ${envelope.errors.map((error) => error.message).join('; ')}`);
142
+ }
143
+ return envelope.data;
144
+ }
145
+ async storiesPage({ team, column, after, }) {
146
+ const data = (await this.query(STORIES_PAGE, { team, column, after: after ?? null }));
147
+ const stories = data.issues.nodes.map((node) => ({
148
+ identifier: node.identifier,
149
+ title: node.title,
150
+ description: node.description,
151
+ completedAt: node.completedAt,
152
+ estimate: node.estimate,
153
+ labels: node.labels.nodes.map((label) => label.name),
154
+ }));
155
+ const { hasNextPage, endCursor } = data.issues.pageInfo;
156
+ return hasNextPage && endCursor !== null ? { stories, nextCursor: endCursor } : { stories };
157
+ }
158
+ async storyIds({ team, column }) {
159
+ const data = (await this.query(STORY_IDS, { team, column }));
160
+ return data.issues.nodes;
161
+ }
162
+ archiveTrackers = [];
163
+ trackArchives() {
164
+ const tracker = [];
165
+ this.archiveTrackers.push(tracker);
166
+ return { data: tracker };
167
+ }
168
+ // Aliases let one request carry many issueArchive calls — a0, a1, a2 and so on — so a
169
+ // page of a hundred costs four requests instead of a hundred. The ids interpolated into
170
+ // the mutation are UUIDs the API itself answered, never user input.
171
+ async archive(ids) {
172
+ if (ids.length === 0) {
173
+ return 0;
174
+ }
175
+ const calls = ids.map((id, index) => `a${index}: issueArchive(id: ${JSON.stringify(id)}) { success }`);
176
+ const data = (await this.query(`mutation { ${calls.join(' ')} }`));
177
+ for (const tracker of this.archiveTrackers) {
178
+ tracker.push(ids);
179
+ }
180
+ return Object.values(data).filter((entry) => entry.success).length;
181
+ }
182
+ // Columns come back in board order — the API answers states in an order of its own,
183
+ // position is the order the board shows.
184
+ async team(key) {
185
+ const data = (await this.query(TEAM, { key }));
186
+ const node = data.teams.nodes[0];
187
+ if (node === undefined) {
188
+ return undefined;
189
+ }
190
+ const states = [...node.states.nodes].sort((a, b) => a.position - b.position);
191
+ return { id: node.id, columns: states.map((state) => state.name) };
192
+ }
193
+ async boardPage({ team, after, }) {
194
+ const data = (await this.query(BOARD_PAGE, { team, after: after ?? null }));
195
+ const rows = data.issues.nodes.map((node) => ({
196
+ identifier: node.identifier,
197
+ column: node.state.name,
198
+ }));
199
+ const { hasNextPage, endCursor } = data.issues.pageInfo;
200
+ return hasNextPage && endCursor !== null ? { rows, nextCursor: endCursor } : { rows };
201
+ }
202
+ createTrackers = [];
203
+ trackCreates() {
204
+ const tracker = [];
205
+ this.createTrackers.push(tracker);
206
+ return { data: tracker };
207
+ }
208
+ async createStory(options) {
209
+ const input = { teamId: options.teamId, title: options.title };
210
+ if (options.description !== undefined) {
211
+ input.description = options.description;
212
+ }
213
+ if (options.parentId !== undefined) {
214
+ input.parentId = options.parentId;
215
+ }
216
+ const data = (await this.query(CREATE_STORY, { input }));
217
+ for (const tracker of this.createTrackers) {
218
+ tracker.push(options);
219
+ }
220
+ return data.issueCreate.issue;
221
+ }
222
+ }
@@ -0,0 +1,149 @@
1
+ const issueNumber = (identifier) => Number(identifier.split('-')[1]);
2
+ // A team or column that does not exist must stop by name, never read as an empty board:
3
+ // user testing hit a typo'd column and a wrong-workspace key inside an hour, and both
4
+ // answered silence. Team first — a wrong-workspace key is an unknown team from here —
5
+ // then the column against the names the board actually has.
6
+ const resolveColumn = async ({ team, column, linear, }) => {
7
+ const found = await linear.team(team);
8
+ if (found === undefined) {
9
+ return { stopped: `no team with key ${team}` };
10
+ }
11
+ if (!found.columns.includes(column)) {
12
+ return { stopped: `no column "${column}" in ${team} (it has: ${found.columns.join(', ')})` };
13
+ }
14
+ return undefined;
15
+ };
16
+ export const storyLine = (story) => story.labels.length === 0
17
+ ? `${story.identifier} ${story.title}`
18
+ : `${story.identifier} [${story.labels.join(',')}] ${story.title}`;
19
+ // The read half of the workshop scripts as policy: pages of a hundred, the cursor from one
20
+ // page becoming the after of the next, merged and sorted by issue number. Every page count
21
+ // is narrated — reading page one and declaring victory is the classic mistake.
22
+ export const storiesFetch = async ({ team, column, linear, narrate, }) => {
23
+ const stopped = await resolveColumn({ team, column, linear });
24
+ if (stopped !== undefined) {
25
+ return stopped;
26
+ }
27
+ const stories = [];
28
+ let after;
29
+ for (let page = 1;; page += 1) {
30
+ const result = await linear.storiesPage({ team, column, after });
31
+ narrate(`page ${page}: ${result.stories.length} stories`);
32
+ stories.push(...result.stories);
33
+ if (result.nextCursor === undefined) {
34
+ break;
35
+ }
36
+ after = result.nextCursor;
37
+ }
38
+ stories.sort((a, b) => issueNumber(a.identifier) - issueNumber(b.identifier));
39
+ return { stories };
40
+ };
41
+ // The board at a glance: one paginated walk of the whole team, counted locally, one row
42
+ // per column in board order. The board's own columns seed the counts so an empty column
43
+ // prints its zero — the point of an overview is the whole board, not the busy part.
44
+ export const storiesOverview = async ({ team, linear, narrate, }) => {
45
+ const found = await linear.team(team);
46
+ if (found === undefined) {
47
+ return { stopped: `no team with key ${team}` };
48
+ }
49
+ const counts = new Map(found.columns.map((column) => [column, 0]));
50
+ let after;
51
+ for (let page = 1;; page += 1) {
52
+ const result = await linear.boardPage({ team, after });
53
+ narrate(`page ${page}: ${result.rows.length} stories`);
54
+ for (const row of result.rows) {
55
+ counts.set(row.column, (counts.get(row.column) ?? 0) + 1);
56
+ }
57
+ if (result.nextCursor === undefined) {
58
+ break;
59
+ }
60
+ after = result.nextCursor;
61
+ }
62
+ return { board: [...counts.entries()].map(([column, count]) => ({ column, count })) };
63
+ };
64
+ // The mutation half: batches of aliased issueArchive calls, re-fetching the first page of
65
+ // the column until it comes back empty — archived stories drop out of default results, so
66
+ // convergence is the loop condition and a re-run after a partial failure is safe. The one
67
+ // human decision stays human: nothing is archived without the confirm answering yes.
68
+ export const storiesArchive = async ({ team, column, linear, confirm, narrate, batchSize = 25, }) => {
69
+ const stopped = await resolveColumn({ team, column, linear });
70
+ if (stopped !== undefined) {
71
+ return stopped;
72
+ }
73
+ let refs = await linear.storyIds({ team, column });
74
+ if (refs.length === 0) {
75
+ narrate(`nothing to archive in ${team}/${column}`);
76
+ return { archived: 0 };
77
+ }
78
+ // A full first page hides the true count behind the cursor, so the question says so.
79
+ const count = refs.length === 100 ? '100 or more' : String(refs.length);
80
+ if (!(await confirm(`archive ${count} stories from ${team}/${column}?`))) {
81
+ return { stopped: 'archive not approved' };
82
+ }
83
+ let archived = 0;
84
+ while (refs.length > 0) {
85
+ for (let start = 0; start < refs.length; start += batchSize) {
86
+ const chunk = refs.slice(start, start + batchSize);
87
+ const successes = await linear.archive(chunk.map((ref) => ref.id));
88
+ if (successes !== chunk.length) {
89
+ return { stopped: `archive fell short: ${successes} of ${chunk.length} in one request` };
90
+ }
91
+ archived += successes;
92
+ narrate(`archived ${archived} so far`);
93
+ }
94
+ refs = await linear.storyIds({ team, column });
95
+ }
96
+ narrate(`${archived} stories archived from ${team}/${column}`);
97
+ return { archived };
98
+ };
99
+ const countStories = (backlog) => backlog.reduce((sum, story) => sum + 1 + countStories(story.children ?? []), 0);
100
+ const hasUntitled = (backlog) => backlog.some((story) => typeof story.title !== 'string' ||
101
+ story.title.trim() === '' ||
102
+ hasUntitled(story.children ?? []));
103
+ // A new project's backlog as a file: stories nest to any depth, and each one is created
104
+ // before its children so the children can carry its id as parentId. --dry-run narrates the
105
+ // would-have tree and never touches the board; the real run confirms with the full count.
106
+ export const storiesCreate = async ({ team, backlog, linear, confirm, narrate, dryRun = false, }) => {
107
+ const count = countStories(backlog);
108
+ if (count === 0) {
109
+ return { stopped: 'the backlog carries no stories' };
110
+ }
111
+ if (hasUntitled(backlog)) {
112
+ return { stopped: 'a backlog story has no title' };
113
+ }
114
+ if (dryRun) {
115
+ const preview = (stories, depth) => {
116
+ for (const story of stories) {
117
+ narrate(`${' '.repeat(depth)}would have created ${story.title}`);
118
+ preview(story.children ?? [], depth + 1);
119
+ }
120
+ };
121
+ preview(backlog, 0);
122
+ narrate(`would have created ${count} stories in ${team}`);
123
+ return { created: 0 };
124
+ }
125
+ const found = await linear.team(team);
126
+ if (found === undefined) {
127
+ return { stopped: `no team with key ${team}` };
128
+ }
129
+ const teamId = found.id;
130
+ if (!(await confirm(`create ${count} stories in ${team}?`))) {
131
+ return { stopped: 'create not approved' };
132
+ }
133
+ let created = 0;
134
+ const create = async (stories, parentId, depth) => {
135
+ for (const story of stories) {
136
+ const card = await linear.createStory({
137
+ teamId,
138
+ title: story.title,
139
+ ...(story.description === undefined ? {} : { description: story.description }),
140
+ ...(parentId === undefined ? {} : { parentId }),
141
+ });
142
+ created += 1;
143
+ narrate(`${' '.repeat(depth)}created ${card.identifier} ${story.title}`);
144
+ await create(story.children ?? [], card.id, depth + 1);
145
+ }
146
+ };
147
+ await create(backlog, undefined, 0);
148
+ return { created };
149
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ekohacks",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "The EkoHacks CLI: our everyday operations, automated one small command at a time.",
5
5
  "repository": {
6
6
  "type": "git",