ekohacks 0.7.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,7 +12,7 @@ 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.
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
16
 
17
17
  ## How this is built
18
18
 
package/dist/cli.js CHANGED
@@ -16,13 +16,13 @@ import { docsCheck, docsDraft, docsSync, draftBranchConflict, draftPrompts, open
16
16
  import { preflight } from './logic/preflight.js';
17
17
  import { release } from './logic/release.js';
18
18
  import { ship } from './logic/ship.js';
19
- import { storiesArchive, storiesCreate, storiesFetch, storyLine, } from './logic/stories.js';
19
+ import { storiesArchive, storiesCreate, storiesFetch, storiesOverview, storyLine, } from './logic/stories.js';
20
20
  const USAGE = [
21
21
  'usage: ekohacks release [preflight|cut|ship] <version> [--yes]',
22
22
  ' ekohacks docs check',
23
23
  ' ekohacks docs sync [--dry-run]',
24
24
  ' ekohacks docs draft [--yes]',
25
- ' ekohacks stories fetch <team> <column> [out.json]',
25
+ ' ekohacks stories fetch <team> [<column> [out.json]]',
26
26
  ' ekohacks stories archive <team> <column> [--yes]',
27
27
  ' ekohacks stories create <team> <backlog.json> [--yes] [--dry-run]',
28
28
  ].join('\n');
@@ -157,10 +157,10 @@ if (command === 'stories') {
157
157
  const team = rest[1];
158
158
  const target = rest[2];
159
159
  const shapeOk = team !== undefined &&
160
- target !== undefined &&
161
160
  ((subject === 'fetch' && rest.length <= 4) ||
162
- (subject === 'archive' && rest.length === 3) ||
163
- (subject === 'create' && rest.length === 3));
161
+ (target !== undefined &&
162
+ ((subject === 'archive' && rest.length === 3) ||
163
+ (subject === 'create' && rest.length === 3))));
164
164
  if (!shapeOk) {
165
165
  console.error(USAGE);
166
166
  process.exit(2);
@@ -172,18 +172,37 @@ if (command === 'stories') {
172
172
  const linear = LinearWrapper.create();
173
173
  const approve = yes ? () => Promise.resolve(true) : confirm;
174
174
  try {
175
- if (subject === 'fetch') {
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) {
176
187
  const out = rest[3];
177
- const { stories } = await storiesFetch({ team, column: target, linear, narrate });
178
- for (const story of stories) {
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) {
179
194
  console.log(storyLine(story));
180
195
  }
181
196
  if (out !== undefined) {
182
- writeFileSync(out, `${JSON.stringify(stories, null, 2)}\n`);
183
- narrate(`${stories.length} stories written to ${out}`);
197
+ writeFileSync(out, `${JSON.stringify(result.stories, null, 2)}\n`);
198
+ narrate(`${result.stories.length} stories written to ${out}`);
184
199
  }
185
200
  process.exit(0);
186
201
  }
202
+ if (target === undefined) {
203
+ console.error(USAGE);
204
+ process.exit(2);
205
+ }
187
206
  if (subject === 'archive') {
188
207
  const result = await storiesArchive({
189
208
  team,
@@ -20,8 +20,19 @@ const STORY_IDS = `query StoryIds($team: String!, $column: String!) {
20
20
  nodes { id identifier }
21
21
  }
22
22
  }`;
23
- const TEAM_ID = `query TeamId($key: String!) {
24
- teams(filter: { key: { eq: $key } }) { nodes { id } }
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
+ }
25
36
  }`;
26
37
  const CREATE_STORY = `mutation CreateStory($input: IssueCreateInput!) {
27
38
  issueCreate(input: $input) { issue { id identifier url } }
@@ -48,7 +59,7 @@ export class LinearWrapper {
48
59
  return (await response.json());
49
60
  });
50
61
  }
51
- static createNull({ pages = [[]], idRounds = [[]], archiveRounds = [], teams = {}, } = {}) {
62
+ static createNull({ pages = [[]], board = [[]], idRounds = [[]], archiveRounds = [], teams = {}, } = {}) {
52
63
  let idRound = 0;
53
64
  let archiveRound = 0;
54
65
  let minted = 0;
@@ -71,8 +82,35 @@ export class LinearWrapper {
71
82
  return answer(Object.fromEntries(calls.map((_, i) => [`a${i}`, { success: i < successes }])));
72
83
  }
73
84
  if (body.query.includes('teams(')) {
74
- const id = teams[body.variables?.key];
75
- return answer({ teams: { nodes: id === undefined ? [] : [{ id }] } });
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
+ });
76
114
  }
77
115
  if (body.query.includes('pageInfo')) {
78
116
  const after = body.variables?.after;
@@ -141,9 +179,25 @@ export class LinearWrapper {
141
179
  }
142
180
  return Object.values(data).filter((entry) => entry.success).length;
143
181
  }
144
- async teamId(key) {
145
- const data = (await this.query(TEAM_ID, { key }));
146
- return data.teams.nodes[0]?.id;
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 };
147
201
  }
148
202
  createTrackers = [];
149
203
  trackCreates() {
@@ -1,9 +1,29 @@
1
1
  const issueNumber = (identifier) => Number(identifier.split('-')[1]);
2
- export const storyLine = (story) => `${story.identifier} [${story.labels.join(',')}] ${story.title}`;
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}`;
3
19
  // The read half of the workshop scripts as policy: pages of a hundred, the cursor from one
4
20
  // page becoming the after of the next, merged and sorted by issue number. Every page count
5
21
  // is narrated — reading page one and declaring victory is the classic mistake.
6
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
+ }
7
27
  const stories = [];
8
28
  let after;
9
29
  for (let page = 1;; page += 1) {
@@ -18,11 +38,38 @@ export const storiesFetch = async ({ team, column, linear, narrate, }) => {
18
38
  stories.sort((a, b) => issueNumber(a.identifier) - issueNumber(b.identifier));
19
39
  return { stories };
20
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
+ };
21
64
  // The mutation half: batches of aliased issueArchive calls, re-fetching the first page of
22
65
  // the column until it comes back empty — archived stories drop out of default results, so
23
66
  // convergence is the loop condition and a re-run after a partial failure is safe. The one
24
67
  // human decision stays human: nothing is archived without the confirm answering yes.
25
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
+ }
26
73
  let refs = await linear.storyIds({ team, column });
27
74
  if (refs.length === 0) {
28
75
  narrate(`nothing to archive in ${team}/${column}`);
@@ -75,10 +122,11 @@ export const storiesCreate = async ({ team, backlog, linear, confirm, narrate, d
75
122
  narrate(`would have created ${count} stories in ${team}`);
76
123
  return { created: 0 };
77
124
  }
78
- const teamId = await linear.teamId(team);
79
- if (teamId === undefined) {
125
+ const found = await linear.team(team);
126
+ if (found === undefined) {
80
127
  return { stopped: `no team with key ${team}` };
81
128
  }
129
+ const teamId = found.id;
82
130
  if (!(await confirm(`create ${count} stories in ${team}?`))) {
83
131
  return { stopped: 'create not approved' };
84
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ekohacks",
3
- "version": "0.7.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",