ekohacks 0.6.0 → 0.7.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.
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, 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,87 @@ 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
+ target !== undefined &&
161
+ ((subject === 'fetch' && rest.length <= 4) ||
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') {
176
+ const out = rest[3];
177
+ const { stories } = await storiesFetch({ team, column: target, linear, narrate });
178
+ for (const story of stories) {
179
+ console.log(storyLine(story));
180
+ }
181
+ if (out !== undefined) {
182
+ writeFileSync(out, `${JSON.stringify(stories, null, 2)}\n`);
183
+ narrate(`${stories.length} stories written to ${out}`);
184
+ }
185
+ process.exit(0);
186
+ }
187
+ if (subject === 'archive') {
188
+ const result = await storiesArchive({
189
+ team,
190
+ column: target,
191
+ linear,
192
+ confirm: approve,
193
+ narrate,
194
+ });
195
+ if ('stopped' in result) {
196
+ console.error(`stopped: ${result.stopped}`);
197
+ process.exit(1);
198
+ }
199
+ process.exit(0);
200
+ }
201
+ if (!existsSync(target)) {
202
+ console.error(`stopped: no ${target} in this directory`);
203
+ process.exit(1);
204
+ }
205
+ let backlog;
206
+ try {
207
+ const parsed = JSON.parse(readFileSync(target, 'utf8'));
208
+ if (!Array.isArray(parsed)) {
209
+ throw new Error('not a list');
210
+ }
211
+ backlog = parsed;
212
+ }
213
+ catch {
214
+ console.error(`stopped: ${target} is not a JSON list of stories`);
215
+ process.exit(1);
216
+ }
217
+ const result = await storiesCreate({
218
+ team,
219
+ backlog,
220
+ linear,
221
+ confirm: approve,
222
+ narrate,
223
+ dryRun,
224
+ });
225
+ if ('stopped' in result) {
226
+ console.error(`stopped: ${result.stopped}`);
227
+ process.exit(1);
228
+ }
229
+ process.exit(0);
230
+ }
231
+ catch (error) {
232
+ console.error(`stopped: ${error.message}`);
233
+ process.exit(1);
234
+ }
235
+ }
143
236
  const first = rest[0];
144
237
  const subcommand = first === 'preflight' || first === 'cut' || first === 'ship' ? first : undefined;
145
238
  const version = subcommand === undefined ? first : rest[1];
@@ -157,13 +250,6 @@ const read = (file) => readFileSync(file, 'utf8');
157
250
  const changelog = read('CHANGELOG.md');
158
251
  const manifest = JSON.parse(read('package.json'));
159
252
  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
253
  if (subcommand === undefined) {
168
254
  const result = await release({
169
255
  version,
@@ -0,0 +1,168 @@
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_ID = `query TeamId($key: String!) {
24
+ teams(filter: { key: { eq: $key } }) { nodes { id } }
25
+ }`;
26
+ const CREATE_STORY = `mutation CreateStory($input: IssueCreateInput!) {
27
+ issueCreate(input: $input) { issue { id identifier url } }
28
+ }`;
29
+ // Wraps the Linear surface the stories commands need, behind the one GraphQL endpoint.
30
+ // Real and null share every line above the bottom layer: create() posts to api.linear.app
31
+ // with the key from the environment, createNull() answers GraphQL-shaped envelopes from
32
+ // configured state and never opens a socket; the real side is proven against a real board
33
+ // rather than a faked Linear. idRounds configures successive answers to storyIds(): each
34
+ // call takes the next round and the last round repeats, so a test can walk a column down
35
+ // to empty. archiveRounds configures how many archives succeed per call; beyond the list,
36
+ // all of them do.
37
+ export class LinearWrapper {
38
+ static create({ apiKey = process.env.LINEAR_API_KEY ?? '', } = {}) {
39
+ return new LinearWrapper(async (body) => {
40
+ const response = await fetch(API, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json', Authorization: apiKey },
43
+ body: JSON.stringify(body),
44
+ });
45
+ if (!response.ok) {
46
+ throw new Error(`linear answered ${response.status}: ${await response.text()}`);
47
+ }
48
+ return (await response.json());
49
+ });
50
+ }
51
+ static createNull({ pages = [[]], idRounds = [[]], archiveRounds = [], teams = {}, } = {}) {
52
+ let idRound = 0;
53
+ let archiveRound = 0;
54
+ let minted = 0;
55
+ return new LinearWrapper((body) => {
56
+ const answer = (data) => Promise.resolve({ data });
57
+ if (body.query.includes('issueCreate')) {
58
+ minted += 1;
59
+ const issue = {
60
+ id: `null-issue-${minted}`,
61
+ identifier: `NULL-${minted}`,
62
+ url: `https://linear.app/nulled/issue/NULL-${minted}`,
63
+ };
64
+ return answer({ issueCreate: { issue } });
65
+ }
66
+ if (body.query.includes('issueArchive')) {
67
+ const calls = body.query.match(/issueArchive/g) ?? [];
68
+ const index = archiveRound;
69
+ archiveRound += 1;
70
+ const successes = archiveRounds[index] ?? calls.length;
71
+ return answer(Object.fromEntries(calls.map((_, i) => [`a${i}`, { success: i < successes }])));
72
+ }
73
+ if (body.query.includes('teams(')) {
74
+ const id = teams[body.variables?.key];
75
+ return answer({ teams: { nodes: id === undefined ? [] : [{ id }] } });
76
+ }
77
+ if (body.query.includes('pageInfo')) {
78
+ const after = body.variables?.after;
79
+ const index = after === null ? 0 : Number(after.replace('cursor-', ''));
80
+ const hasNextPage = index < pages.length - 1;
81
+ return answer({
82
+ issues: {
83
+ pageInfo: { hasNextPage, endCursor: hasNextPage ? `cursor-${index + 1}` : null },
84
+ nodes: (pages[index] ?? []).map((story) => ({
85
+ ...story,
86
+ labels: { nodes: story.labels.map((name) => ({ name })) },
87
+ })),
88
+ },
89
+ });
90
+ }
91
+ const round = Math.min(idRound, idRounds.length - 1);
92
+ idRound += 1;
93
+ return answer({ issues: { nodes: idRounds[round] ?? [] } });
94
+ });
95
+ }
96
+ post;
97
+ constructor(post) {
98
+ this.post = post;
99
+ }
100
+ async query(query, variables) {
101
+ const envelope = await this.post({ query, variables });
102
+ if (envelope.errors !== undefined && envelope.errors.length > 0) {
103
+ throw new Error(`linear answered: ${envelope.errors.map((error) => error.message).join('; ')}`);
104
+ }
105
+ return envelope.data;
106
+ }
107
+ async storiesPage({ team, column, after, }) {
108
+ const data = (await this.query(STORIES_PAGE, { team, column, after: after ?? null }));
109
+ const stories = data.issues.nodes.map((node) => ({
110
+ identifier: node.identifier,
111
+ title: node.title,
112
+ description: node.description,
113
+ completedAt: node.completedAt,
114
+ estimate: node.estimate,
115
+ labels: node.labels.nodes.map((label) => label.name),
116
+ }));
117
+ const { hasNextPage, endCursor } = data.issues.pageInfo;
118
+ return hasNextPage && endCursor !== null ? { stories, nextCursor: endCursor } : { stories };
119
+ }
120
+ async storyIds({ team, column }) {
121
+ const data = (await this.query(STORY_IDS, { team, column }));
122
+ return data.issues.nodes;
123
+ }
124
+ archiveTrackers = [];
125
+ trackArchives() {
126
+ const tracker = [];
127
+ this.archiveTrackers.push(tracker);
128
+ return { data: tracker };
129
+ }
130
+ // Aliases let one request carry many issueArchive calls — a0, a1, a2 and so on — so a
131
+ // page of a hundred costs four requests instead of a hundred. The ids interpolated into
132
+ // the mutation are UUIDs the API itself answered, never user input.
133
+ async archive(ids) {
134
+ if (ids.length === 0) {
135
+ return 0;
136
+ }
137
+ const calls = ids.map((id, index) => `a${index}: issueArchive(id: ${JSON.stringify(id)}) { success }`);
138
+ const data = (await this.query(`mutation { ${calls.join(' ')} }`));
139
+ for (const tracker of this.archiveTrackers) {
140
+ tracker.push(ids);
141
+ }
142
+ return Object.values(data).filter((entry) => entry.success).length;
143
+ }
144
+ async teamId(key) {
145
+ const data = (await this.query(TEAM_ID, { key }));
146
+ return data.teams.nodes[0]?.id;
147
+ }
148
+ createTrackers = [];
149
+ trackCreates() {
150
+ const tracker = [];
151
+ this.createTrackers.push(tracker);
152
+ return { data: tracker };
153
+ }
154
+ async createStory(options) {
155
+ const input = { teamId: options.teamId, title: options.title };
156
+ if (options.description !== undefined) {
157
+ input.description = options.description;
158
+ }
159
+ if (options.parentId !== undefined) {
160
+ input.parentId = options.parentId;
161
+ }
162
+ const data = (await this.query(CREATE_STORY, { input }));
163
+ for (const tracker of this.createTrackers) {
164
+ tracker.push(options);
165
+ }
166
+ return data.issueCreate.issue;
167
+ }
168
+ }
@@ -0,0 +1,101 @@
1
+ const issueNumber = (identifier) => Number(identifier.split('-')[1]);
2
+ export const storyLine = (story) => `${story.identifier} [${story.labels.join(',')}] ${story.title}`;
3
+ // The read half of the workshop scripts as policy: pages of a hundred, the cursor from one
4
+ // page becoming the after of the next, merged and sorted by issue number. Every page count
5
+ // is narrated — reading page one and declaring victory is the classic mistake.
6
+ export const storiesFetch = async ({ team, column, linear, narrate, }) => {
7
+ const stories = [];
8
+ let after;
9
+ for (let page = 1;; page += 1) {
10
+ const result = await linear.storiesPage({ team, column, after });
11
+ narrate(`page ${page}: ${result.stories.length} stories`);
12
+ stories.push(...result.stories);
13
+ if (result.nextCursor === undefined) {
14
+ break;
15
+ }
16
+ after = result.nextCursor;
17
+ }
18
+ stories.sort((a, b) => issueNumber(a.identifier) - issueNumber(b.identifier));
19
+ return { stories };
20
+ };
21
+ // The mutation half: batches of aliased issueArchive calls, re-fetching the first page of
22
+ // the column until it comes back empty — archived stories drop out of default results, so
23
+ // convergence is the loop condition and a re-run after a partial failure is safe. The one
24
+ // human decision stays human: nothing is archived without the confirm answering yes.
25
+ export const storiesArchive = async ({ team, column, linear, confirm, narrate, batchSize = 25, }) => {
26
+ let refs = await linear.storyIds({ team, column });
27
+ if (refs.length === 0) {
28
+ narrate(`nothing to archive in ${team}/${column}`);
29
+ return { archived: 0 };
30
+ }
31
+ // A full first page hides the true count behind the cursor, so the question says so.
32
+ const count = refs.length === 100 ? '100 or more' : String(refs.length);
33
+ if (!(await confirm(`archive ${count} stories from ${team}/${column}?`))) {
34
+ return { stopped: 'archive not approved' };
35
+ }
36
+ let archived = 0;
37
+ while (refs.length > 0) {
38
+ for (let start = 0; start < refs.length; start += batchSize) {
39
+ const chunk = refs.slice(start, start + batchSize);
40
+ const successes = await linear.archive(chunk.map((ref) => ref.id));
41
+ if (successes !== chunk.length) {
42
+ return { stopped: `archive fell short: ${successes} of ${chunk.length} in one request` };
43
+ }
44
+ archived += successes;
45
+ narrate(`archived ${archived} so far`);
46
+ }
47
+ refs = await linear.storyIds({ team, column });
48
+ }
49
+ narrate(`${archived} stories archived from ${team}/${column}`);
50
+ return { archived };
51
+ };
52
+ const countStories = (backlog) => backlog.reduce((sum, story) => sum + 1 + countStories(story.children ?? []), 0);
53
+ const hasUntitled = (backlog) => backlog.some((story) => typeof story.title !== 'string' ||
54
+ story.title.trim() === '' ||
55
+ hasUntitled(story.children ?? []));
56
+ // A new project's backlog as a file: stories nest to any depth, and each one is created
57
+ // before its children so the children can carry its id as parentId. --dry-run narrates the
58
+ // would-have tree and never touches the board; the real run confirms with the full count.
59
+ export const storiesCreate = async ({ team, backlog, linear, confirm, narrate, dryRun = false, }) => {
60
+ const count = countStories(backlog);
61
+ if (count === 0) {
62
+ return { stopped: 'the backlog carries no stories' };
63
+ }
64
+ if (hasUntitled(backlog)) {
65
+ return { stopped: 'a backlog story has no title' };
66
+ }
67
+ if (dryRun) {
68
+ const preview = (stories, depth) => {
69
+ for (const story of stories) {
70
+ narrate(`${' '.repeat(depth)}would have created ${story.title}`);
71
+ preview(story.children ?? [], depth + 1);
72
+ }
73
+ };
74
+ preview(backlog, 0);
75
+ narrate(`would have created ${count} stories in ${team}`);
76
+ return { created: 0 };
77
+ }
78
+ const teamId = await linear.teamId(team);
79
+ if (teamId === undefined) {
80
+ return { stopped: `no team with key ${team}` };
81
+ }
82
+ if (!(await confirm(`create ${count} stories in ${team}?`))) {
83
+ return { stopped: 'create not approved' };
84
+ }
85
+ let created = 0;
86
+ const create = async (stories, parentId, depth) => {
87
+ for (const story of stories) {
88
+ const card = await linear.createStory({
89
+ teamId,
90
+ title: story.title,
91
+ ...(story.description === undefined ? {} : { description: story.description }),
92
+ ...(parentId === undefined ? {} : { parentId }),
93
+ });
94
+ created += 1;
95
+ narrate(`${' '.repeat(depth)}created ${card.identifier} ${story.title}`);
96
+ await create(story.children ?? [], card.id, depth + 1);
97
+ }
98
+ };
99
+ await create(backlog, undefined, 0);
100
+ return { created };
101
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ekohacks",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The EkoHacks CLI: our everyday operations, automated one small command at a time.",
5
5
  "repository": {
6
6
  "type": "git",