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 +1 -1
- package/dist/cli.js +29 -10
- package/dist/infrastructure/linear.js +62 -8
- package/dist/logic/stories.js +51 -3
- package/package.json +1 -1
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
|
-
(
|
|
163
|
-
|
|
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
|
|
178
|
-
|
|
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
|
|
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
|
|
75
|
-
return answer({
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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() {
|
package/dist/logic/stories.js
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
const issueNumber = (identifier) => Number(identifier.split('-')[1]);
|
|
2
|
-
|
|
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
|
|
79
|
-
if (
|
|
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
|
}
|